From c3f13068a4eb864d4186598509cc81edc43c6cd1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:44:15 +0000 Subject: [PATCH 1/4] Initial plan From 59ba52fdaedfe83796a5a2b122ca510d4112b04e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:50:39 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=93=A6=20new:=20add=20biome.json=20an?= =?UTF-8?q?d=20update=20package.json=20for=20linting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: warengonzaga <15052701+warengonzaga@users.noreply.github.com> --- biome.json | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 biome.json diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..958d7e2 --- /dev/null +++ b/biome.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} From ddb2d53986fda08f01e02f568809d7b5d2695eff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:06:13 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=94=A7=20update:=20fix=20all=20biome?= =?UTF-8?q?=20lint=20errors=20across=20codebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: warengonzaga <15052701+warengonzaga@users.noreply.github.com> --- bun.lock | 74 +- package.json | 4 + packages/compactor/src/ccp.ts | 6 +- packages/compactor/src/compactor.ts | 26 +- packages/compactor/src/dedup.ts | 4 +- packages/compactor/src/dictionary.ts | 18 +- packages/compactor/src/index.ts | 55 +- packages/compactor/src/optimizer.ts | 26 +- packages/compactor/src/rules.ts | 58 +- packages/compactor/src/tokens.ts | 5 +- packages/compactor/src/types.ts | 2 +- packages/compactor/tests/ccp.test.ts | 7 +- packages/compactor/tests/compactor.test.ts | 61 +- packages/compactor/tests/dedup.test.ts | 8 +- packages/compactor/tests/dictionary.test.ts | 4 +- packages/compactor/tests/optimizer.test.ts | 8 +- packages/compactor/tests/rules.test.ts | 17 +- packages/compactor/tests/tiers.test.ts | 5 +- packages/compactor/tests/tokens.test.ts | 2 +- packages/config/src/index.ts | 12 +- packages/config/src/manager.ts | 9 +- packages/config/src/tools.ts | 42 +- packages/config/src/types.ts | 314 +++-- packages/config/tests/manager.test.ts | 14 +- packages/config/tests/tools.test.ts | 16 +- packages/config/tests/types.test.ts | 4 +- packages/core/src/database.ts | 295 +++- packages/core/src/index.ts | 67 +- packages/core/src/llm.ts | 37 +- packages/core/src/loop.ts | 263 ++-- packages/core/src/messages.ts | 32 +- packages/core/src/owner-auth.ts | 120 +- packages/core/src/update-checker.ts | 28 +- packages/core/tests/update-checker.test.ts | 30 +- packages/delegation/src/background.ts | 31 +- packages/delegation/src/blackboard.ts | 2 +- packages/delegation/src/compat.ts | 22 +- packages/delegation/src/index.ts | 73 +- packages/delegation/src/lifecycle.ts | 94 +- packages/delegation/src/orientation.ts | 10 +- packages/delegation/src/runner.ts | 65 +- packages/delegation/src/store.ts | 13 +- packages/delegation/src/templates.ts | 90 +- packages/delegation/src/timeout-estimator.ts | 75 +- packages/delegation/src/tools.ts | 85 +- packages/delegation/src/types.ts | 7 +- packages/delegation/tests/blackboard.test.ts | 61 +- packages/delegation/tests/db.test.ts | 147 +- packages/delegation/tests/delegation.test.ts | 36 +- packages/delegation/tests/lifecycle.test.ts | 112 +- packages/delegation/tests/templates.test.ts | 8 +- .../tests/timeout-estimator.test.ts | 31 +- packages/delegation/tests/tools.test.ts | 61 +- packages/gateway/src/index.ts | 13 +- packages/gateway/tests/gateway.test.ts | 36 +- packages/heartware/src/audit.ts | 34 +- packages/heartware/src/backup.ts | 24 +- packages/heartware/src/errors.ts | 21 +- packages/heartware/src/index.ts | 87 +- packages/heartware/src/loader.ts | 48 +- packages/heartware/src/manager.ts | 105 +- packages/heartware/src/meta.ts | 10 +- packages/heartware/src/rate-limiter.ts | 28 +- packages/heartware/src/sandbox.ts | 74 +- packages/heartware/src/soul-generator.ts | 104 +- packages/heartware/src/soul-traits.ts | 187 ++- packages/heartware/src/templates.ts | 2 +- packages/heartware/src/tools.ts | 136 +- packages/heartware/tests/meta.test.ts | 17 +- .../heartware/tests/soul-generator.test.ts | 27 +- packages/intercom/src/index.ts | 2 +- packages/intercom/tests/intercom.test.ts | 24 +- packages/learning/src/detector.ts | 5 +- packages/learning/src/index.ts | 32 +- packages/logger/src/index.ts | 30 +- packages/matcher/src/index.ts | 75 +- packages/matcher/tests/matcher.test.ts | 17 +- packages/memory/src/index.ts | 70 +- packages/memory/tests/memory-engine.test.ts | 84 +- packages/nudge/src/companion.ts | 24 +- packages/nudge/src/index.ts | 12 +- packages/nudge/tests/nudge.test.ts | 36 +- packages/plugins/src/index.ts | 12 +- packages/pulse/src/index.ts | 6 +- packages/queue/src/index.ts | 26 +- packages/queue/tests/queue.test.ts | 14 +- packages/router/src/classifier.ts | 147 +- packages/router/src/index.ts | 16 +- packages/router/src/orchestrator.ts | 10 +- packages/router/src/provider-registry.ts | 11 +- packages/sandbox/src/index.ts | 12 +- packages/sandbox/src/worker.ts | 2 +- packages/sandbox/tests/sandbox.test.ts | 29 +- packages/secrets/src/index.ts | 7 +- packages/secrets/src/manager.ts | 4 +- packages/secrets/src/tools.ts | 47 +- packages/shell/src/executor.ts | 16 +- packages/shell/src/index.ts | 27 +- packages/shell/src/permissions.ts | 29 +- packages/shell/tests/executor.test.ts | 21 +- packages/shell/tests/permissions.test.ts | 17 +- packages/shield/src/engine.ts | 8 +- packages/shield/src/index.ts | 7 +- packages/shield/src/matcher.ts | 54 +- packages/shield/src/parser.ts | 68 +- packages/shield/tests/engine.test.ts | 6 +- packages/shield/tests/matcher.test.ts | 14 +- packages/shield/tests/parser.test.ts | 30 +- packages/types/src/index.ts | 93 +- .../plugin-channel-discord/src/index.ts | 35 +- .../plugin-channel-discord/src/pairing.ts | 2 +- .../tests/index.test.ts | 4 +- .../tests/pairing.test.ts | 6 +- .../plugin-channel-friends/src/index.ts | 19 +- .../plugin-channel-friends/src/server.ts | 17 +- .../plugin-channel-friends/src/store.ts | 25 +- .../plugin-channel-friends/src/tools.ts | 25 +- .../plugin-provider-openai/src/index.ts | 9 +- .../plugin-provider-openai/src/pairing.ts | 2 +- .../plugin-provider-openai/src/provider.ts | 19 +- src/cli/src/commands/backup.ts | 128 +- src/cli/src/commands/config.ts | 63 +- src/cli/src/commands/purge.ts | 43 +- src/cli/src/commands/seed.ts | 48 +- src/cli/src/commands/setup-web.ts | 87 +- src/cli/src/commands/setup.ts | 391 +++--- src/cli/src/commands/start.ts | 330 +++-- src/cli/src/index.ts | 20 +- src/cli/src/supervisor.ts | 9 +- src/cli/src/ui/banner.ts | 6 +- src/cli/tests/cli-router.test.ts | 8 +- src/cli/tests/commands/backup.test.ts | 7 +- src/cli/tests/commands/setup.test.ts | 39 +- src/cli/tests/commands/start.test.ts | 21 +- src/cli/tests/config.test.ts | 21 +- src/cli/tests/purge.test.ts | 15 +- src/cli/tests/ui/banner.test.ts | 2 +- src/cli/tests/ui/theme.test.ts | 11 +- src/landing/src/main.js | 12 +- src/landing/vite.config.ts | 10 +- src/web/src/hatching-scene.js | 1114 ++++++++------- src/web/src/main.js | 26 +- src/web/src/preview-hatching.js | 14 +- src/web/src/security-db.ts | 78 +- src/web/src/server.ts | 1250 +++++++++-------- src/web/tests/main.test.ts | 2 +- src/web/tests/security-db.test.ts | 151 +- src/web/tests/server.test.ts | 24 +- src/web/vite.config.ts | 10 +- 149 files changed, 5038 insertions(+), 3858 deletions(-) diff --git a/bun.lock b/bun.lock index 6067484..f7badcb 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@wgtechlabs/log-engine": "^2.3.1", }, "devDependencies": { + "@biomejs/biome": "^2.4.4", "@types/bun": "latest", "@types/node": "^22.10.0", "husky": "^9.1.7", @@ -16,7 +17,7 @@ }, "packages/compactor": { "name": "@tinyclaw/compactor", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/logger": "workspace:*", "@tinyclaw/types": "workspace:*", @@ -28,7 +29,7 @@ }, "packages/config": { "name": "@tinyclaw/config", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/core": "workspace:*", "@tinyclaw/logger": "workspace:*", @@ -39,7 +40,7 @@ }, "packages/core": { "name": "@tinyclaw/core", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/delegation": "workspace:*", "@tinyclaw/logger": "workspace:*", @@ -51,7 +52,7 @@ }, "packages/delegation": { "name": "@tinyclaw/delegation", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/logger": "workspace:*", "@tinyclaw/router": "workspace:*", @@ -65,7 +66,7 @@ }, "packages/gateway": { "name": "@tinyclaw/gateway", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/logger": "workspace:*", "@tinyclaw/types": "workspace:*", @@ -73,7 +74,7 @@ }, "packages/heartware": { "name": "@tinyclaw/heartware", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/logger": "workspace:*", "@tinyclaw/types": "workspace:*", @@ -81,29 +82,29 @@ }, "packages/intercom": { "name": "@tinyclaw/intercom", - "version": "1.1.0", + "version": "2.0.0", }, "packages/learning": { "name": "@tinyclaw/learning", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/types": "workspace:*", }, }, "packages/logger": { "name": "@tinyclaw/logger", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@wgtechlabs/log-engine": "^2.3.0", }, }, "packages/matcher": { "name": "@tinyclaw/matcher", - "version": "1.1.0", + "version": "2.0.0", }, "packages/memory": { "name": "@tinyclaw/memory", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/types": "workspace:*", }, @@ -113,7 +114,7 @@ }, "packages/nudge": { "name": "@tinyclaw/nudge", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/logger": "workspace:*", "@tinyclaw/types": "workspace:*", @@ -121,7 +122,7 @@ }, "packages/plugins": { "name": "@tinyclaw/plugins", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/logger": "workspace:*", "@tinyclaw/types": "workspace:*", @@ -129,7 +130,7 @@ }, "packages/pulse": { "name": "@tinyclaw/pulse", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/logger": "workspace:*", "@tinyclaw/types": "workspace:*", @@ -137,11 +138,11 @@ }, "packages/queue": { "name": "@tinyclaw/queue", - "version": "1.1.0", + "version": "2.0.0", }, "packages/router": { "name": "@tinyclaw/router", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/logger": "workspace:*", "@tinyclaw/types": "workspace:*", @@ -149,11 +150,11 @@ }, "packages/sandbox": { "name": "@tinyclaw/sandbox", - "version": "1.1.0", + "version": "2.0.0", }, "packages/secrets": { "name": "@tinyclaw/secrets", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/logger": "workspace:*", "@tinyclaw/types": "workspace:*", @@ -162,7 +163,7 @@ }, "packages/shell": { "name": "@tinyclaw/shell", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/logger": "workspace:*", "@tinyclaw/types": "workspace:*", @@ -170,7 +171,7 @@ }, "packages/shield": { "name": "@tinyclaw/shield", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/logger": "workspace:*", "@tinyclaw/types": "workspace:*", @@ -178,11 +179,11 @@ }, "packages/types": { "name": "@tinyclaw/types", - "version": "1.1.0", + "version": "2.0.0", }, "plugins/channel/plugin-channel-discord": { "name": "@tinyclaw/plugin-channel-discord", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/logger": "workspace:*", "@tinyclaw/types": "workspace:*", @@ -191,7 +192,7 @@ }, "plugins/channel/plugin-channel-friends": { "name": "@tinyclaw/plugin-channel-friends", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/logger": "workspace:*", "@tinyclaw/types": "workspace:*", @@ -199,7 +200,7 @@ }, "plugins/provider/plugin-provider-openai": { "name": "@tinyclaw/plugin-provider-openai", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/logger": "workspace:*", "@tinyclaw/types": "workspace:*", @@ -207,7 +208,7 @@ }, "src/cli": { "name": "tinyclaw", - "version": "1.1.0", + "version": "2.0.0", "bin": { "tinyclaw": "./dist/index.js", }, @@ -245,7 +246,7 @@ }, "src/landing": { "name": "@tinyclaw/landing", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "svelte": "^5.20.1", }, @@ -258,7 +259,7 @@ }, "src/web": { "name": "@tinyclaw/web", - "version": "1.1.0", + "version": "2.0.0", "dependencies": { "@tinyclaw/core": "workspace:*", "@tinyclaw/heartware": "workspace:*", @@ -282,6 +283,24 @@ "undici": "6.23.0", }, "packages": { + "@biomejs/biome": ["@biomejs/biome@2.4.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.4", "@biomejs/cli-darwin-x64": "2.4.4", "@biomejs/cli-linux-arm64": "2.4.4", "@biomejs/cli-linux-arm64-musl": "2.4.4", "@biomejs/cli-linux-x64": "2.4.4", "@biomejs/cli-linux-x64-musl": "2.4.4", "@biomejs/cli-win32-arm64": "2.4.4", "@biomejs/cli-win32-x64": "2.4.4" }, "bin": { "biome": "bin/biome" } }, "sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="], + "@clack/core": ["@clack/core@1.0.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ=="], "@clack/prompts": ["@clack/prompts@1.0.0", "", { "dependencies": { "@clack/core": "1.0.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A=="], @@ -731,3 +750,4 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], } } + diff --git a/package.json b/package.json index b2be249..907d3ee 100644 --- a/package.json +++ b/package.json @@ -23,12 +23,16 @@ "build:all": "bun run build:packages && bun run build:plugins && bun run build:apps", "start": "bun run --cwd src/cli start", "test": "bun test", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", "cli": "bun run src/cli/src/index.ts", "dev:purge": "bun run cli purge --force", "dev:landing": "bun run --cwd src/landing dev", "prepare": "husky" }, "devDependencies": { + "@biomejs/biome": "^2.4.4", "@types/bun": "latest", "@types/node": "^22.10.0", "husky": "^9.1.7", diff --git a/packages/compactor/src/ccp.ts b/packages/compactor/src/ccp.ts index 1be43de..c6081ae 100644 --- a/packages/compactor/src/ccp.ts +++ b/packages/compactor/src/ccp.ts @@ -195,7 +195,7 @@ function compressUltra(text: string): string { result = replaceWord(result, 'for', '4'); // Clean up spacing - result = result.replace(/ +/g, ' '); + result = result.replace(/ {2,}/g, ' '); result = result.replace(/\n{3,}/g, '\n\n'); result = result.replace(/^\s+/gm, ''); @@ -217,7 +217,7 @@ function compressMedium(text: string): string { } // Clean up - result = result.replace(/ +/g, ' '); + result = result.replace(/ {2,}/g, ' '); result = result.replace(/\n{3,}/g, '\n\n'); return result.trim(); @@ -226,7 +226,7 @@ function compressMedium(text: string): string { function compressLight(text: string): string { if (!text) return ''; let result = text; - result = result.replace(/ +/g, ' '); + result = result.replace(/ {2,}/g, ' '); result = result.replace(/\n{3,}/g, '\n\n'); return result.trim(); } diff --git a/packages/compactor/src/compactor.ts b/packages/compactor/src/compactor.ts index e68a1d1..edae086 100644 --- a/packages/compactor/src/compactor.ts +++ b/packages/compactor/src/compactor.ts @@ -13,17 +13,17 @@ import { logger } from '@tinyclaw/logger'; import type { Provider } from '@tinyclaw/types'; +import { deduplicateMessages } from './dedup.js'; +import { preCompress } from './rules.js'; +import { generateTiers } from './tiers.js'; +import { estimateTokens } from './tokens.js'; import type { - CompactorStore, + CompactionResult, CompactorConfig, CompactorEngine, - CompactionResult, + CompactorStore, } from './types.js'; import { DEFAULT_COMPACTOR_CONFIG } from './types.js'; -import { estimateTokens } from './tokens.js'; -import { preCompress } from './rules.js'; -import { deduplicateMessages } from './dedup.js'; -import { generateTiers } from './tiers.js'; // --------------------------------------------------------------------------- // Factory @@ -57,10 +57,7 @@ export function createCompactor( }; return { - async compactIfNeeded( - userId: string, - provider: Provider, - ): Promise { + async compactIfNeeded(userId: string, provider: Provider): Promise { const count = db.getMessageCount(userId); if (count < cfg.threshold) return null; @@ -84,18 +81,13 @@ export function createCompactor( // 3. Deduplicate near-identical messages let dedupGroupsRemoved = 0; if (cfg.dedup.enabled) { - const dedupResult = deduplicateMessages( - oldMessages, - cfg.dedup.similarityThreshold, - ); + const dedupResult = deduplicateMessages(oldMessages, cfg.dedup.similarityThreshold); oldMessages = dedupResult.messages; dedupGroupsRemoved = dedupResult.groupsRemoved; } // 4. Estimate tokens of cleaned content - const summaryContent = oldMessages - .map((m) => `${m.role}: ${m.content}`) - .join('\n'); + const summaryContent = oldMessages.map((m) => `${m.role}: ${m.content}`).join('\n'); const tokensBefore = estimateTokens(summaryContent); // 5. Send to LLM for summarization diff --git a/packages/compactor/src/dedup.ts b/packages/compactor/src/dedup.ts index 362cd0c..876c672 100644 --- a/packages/compactor/src/dedup.ts +++ b/packages/compactor/src/dedup.ts @@ -111,9 +111,7 @@ export function deduplicateMessages( } } - const result = shingled - .filter((s) => !dropped.has(s.index)) - .map((s) => s.message); + const result = shingled.filter((s) => !dropped.has(s.index)).map((s) => s.message); return { messages: result, groupsRemoved }; } diff --git a/packages/compactor/src/dictionary.ts b/packages/compactor/src/dictionary.ts index a014d89..204147b 100644 --- a/packages/compactor/src/dictionary.ts +++ b/packages/compactor/src/dictionary.ts @@ -38,7 +38,7 @@ function generateCodes(n: number): string[] { // 2-letter codes: $AA .. $ZZ (676) for (let i = 0; i < 26 && codes.length < n; i++) { for (let j = 0; j < 26 && codes.length < n; j++) { - codes.push('$' + String.fromCharCode(A + i) + String.fromCharCode(A + j)); + codes.push(`$${String.fromCharCode(A + i)}${String.fromCharCode(A + j)}`); } } @@ -47,7 +47,10 @@ function generateCodes(n: number): string[] { for (let j = 0; j < 26 && codes.length < n; j++) { for (let k = 0; k < 26 && codes.length < n; k++) { codes.push( - '$' + String.fromCharCode(A + i) + String.fromCharCode(A + j) + String.fromCharCode(A + k), + '$' + + String.fromCharCode(A + i) + + String.fromCharCode(A + j) + + String.fromCharCode(A + k), ); } } @@ -63,11 +66,7 @@ function generateCodes(n: number): string[] { /** * Extract word n-grams from text, filtering by minimum phrase length. */ -function tokenizeNgrams( - text: string, - minN: number = 2, - maxN: number = 5, -): Map { +function tokenizeNgrams(text: string, minN: number = 2, maxN: number = 5): Map { const counter = new Map(); if (!text) return counter; @@ -104,10 +103,7 @@ export interface BuildCodebookOptions { * Scans for high-frequency n-grams and returns a mapping of short codes * to the phrases they replace, sorted by savings potential. */ -export function buildCodebook( - texts: string[], - options: BuildCodebookOptions = {}, -): Codebook { +export function buildCodebook(texts: string[], options: BuildCodebookOptions = {}): Codebook { const { minFreq = DEFAULT_MIN_FREQ, maxEntries = DEFAULT_MAX_ENTRIES } = options; if (!texts.length) return {}; diff --git a/packages/compactor/src/index.ts b/packages/compactor/src/index.ts index e32c1c4..cff8732 100644 --- a/packages/compactor/src/index.ts +++ b/packages/compactor/src/index.ts @@ -21,55 +21,54 @@ * await compactor.compactIfNeeded(userId, provider); */ +export type { CcpLevel, CcpResult, CcpResultWithStats } from './ccp.js'; +// Compressed Context Protocol (standalone utility) +export { compressContext, compressContextWithStats } from './ccp.js'; // Core pipeline export { createCompactor } from './compactor.js'; -export { estimateTokens, truncateToTokenBudget } from './tokens.js'; -export { - preCompress, - stripEmoji, - deduplicateLines, - collapseWhitespace, - removeDecorativeLines, - normalizeCjkPunctuation, - removeEmptySections, - compressMarkdownTable, - mergeSimilarBullets, - mergeShortBullets, -} from './rules.js'; -export { deduplicateMessages, computeShingles, jaccardSimilarity } from './dedup.js'; -export { generateTiers } from './tiers.js'; +export { computeShingles, deduplicateMessages, jaccardSimilarity } from './dedup.js'; +export type { BuildCodebookOptions, Codebook } from './dictionary.js'; // Dictionary encoding (standalone utility) export { buildCodebook, + compressionStats, compressText, decompressText, - compressionStats, } from './dictionary.js'; -export type { Codebook, BuildCodebookOptions } from './dictionary.js'; +export type { OptimizerOptions } from './optimizer.js'; // Tokenizer optimizer (standalone utility) export { + compactBullets, + compressTableToKv, + minimizeWhitespace, optimizeTokens, stripBoldItalic, stripTrivialBackticks, - minimizeWhitespace, - compactBullets, - compressTableToKv, } from './optimizer.js'; -export type { OptimizerOptions } from './optimizer.js'; - -// Compressed Context Protocol (standalone utility) -export { compressContext, compressContextWithStats } from './ccp.js'; -export type { CcpLevel, CcpResult, CcpResultWithStats } from './ccp.js'; +export { + collapseWhitespace, + compressMarkdownTable, + deduplicateLines, + mergeShortBullets, + mergeSimilarBullets, + normalizeCjkPunctuation, + preCompress, + removeDecorativeLines, + removeEmptySections, + stripEmoji, +} from './rules.js'; +export { generateTiers } from './tiers.js'; +export { estimateTokens, truncateToTokenBudget } from './tokens.js'; // Types export type { - CompactorStore, + CompactionMetrics, + CompactionResult, CompactorConfig, CompactorEngine, - CompactionResult, - CompactionMetrics, + CompactorStore, TieredSummary, } from './types.js'; export { DEFAULT_COMPACTOR_CONFIG } from './types.js'; diff --git a/packages/compactor/src/optimizer.ts b/packages/compactor/src/optimizer.ts index 890aaaa..a96019a 100644 --- a/packages/compactor/src/optimizer.ts +++ b/packages/compactor/src/optimizer.ts @@ -20,9 +20,9 @@ import { normalizeCjkPunctuation } from './rules.js'; const BOLD_RE = /\*\*(.+?)\*\*/g; const ITALIC_RE = /(? c.trim()); + if (line.includes('|') && i + 1 < lines.length && TABLE_SEP_RE.test(lines[i + 1].trim())) { + const headers = line + .trim() + .replace(/^\||\|$/g, '') + .split('|') + .map((c) => c.trim()); i += 2; const rows: string[][] = []; while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { - const cells = lines[i].trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim()); + const cells = lines[i] + .trim() + .replace(/^\||\|$/g, '') + .split('|') + .map((c) => c.trim()); rows.push(cells); i++; } diff --git a/packages/compactor/src/rules.ts b/packages/compactor/src/rules.ts index adaa0ba..a6c71b7 100644 --- a/packages/compactor/src/rules.ts +++ b/packages/compactor/src/rules.ts @@ -22,27 +22,44 @@ // --------------------------------------------------------------------------- // Matches most emoji: emoticons, dingbats, symbols, skin tones, ZWJ sequences +// biome-ignore lint/suspicious/noMisleadingCharacterClass: intentional emoji regex with variation selectors and combining characters const EMOJI_REGEX = + // biome-ignore lint/suspicious/noMisleadingCharacterClass: intentional emoji regex with variation selectors and combining characters /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{200D}\u{20E3}\u{E0020}-\u{E007F}]/gu; // Markdown header const HEADER_RE = /^(#{1,6})\s+(.*)/; // Table separator line -const TABLE_SEP_RE = /^[\s|:\-]+$/; +const TABLE_SEP_RE = /^[\s|:-]+$/; // Bullet line const BULLET_RE = /^(\s*[-*+]\s+)(.*)/; // Chinese fullwidth punctuation -> ASCII equivalents (each saves ~1 token) const ZH_PUNCT_MAP: Record = { - '\uFF0C': ',', '\u3002': '.', '\uFF1B': ';', '\uFF1A': ':', '\uFF01': '!', '\uFF1F': '?', - '\u201C': '"', '\u201D': '"', '\u2018': "'", '\u2019': "'", - '\uFF08': '(', '\uFF09': ')', '\u3010': '[', '\u3011': ']', - '\u3001': ',', '\u2026': '...', '\uFF5E': '~', + '\uFF0C': ',', + '\u3002': '.', + '\uFF1B': ';', + '\uFF1A': ':', + '\uFF01': '!', + '\uFF1F': '?', + '\u201C': '"', + '\u201D': '"', + '\u2018': "'", + '\u2019': "'", + '\uFF08': '(', + '\uFF09': ')', + '\u3010': '[', + '\u3011': ']', + '\u3001': ',', + '\u2026': '...', + '\uFF5E': '~', }; const ZH_PUNCT_RE = new RegExp( - Object.keys(ZH_PUNCT_MAP).map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), + Object.keys(ZH_PUNCT_MAP) + .map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|'), 'g', ); @@ -65,7 +82,10 @@ export function normalizeCjkPunctuation(text: string): string { * Remove emoji characters from text. */ export function stripEmoji(text: string): string { - return text.replace(EMOJI_REGEX, '').replace(/\s{2,}/g, ' ').trim(); + return text + .replace(EMOJI_REGEX, '') + .replace(/\s{2,}/g, ' ') + .trim(); } /** @@ -159,7 +179,7 @@ export function removeEmptySections(text: string): string { const body = sec.bodyLines.join('\n').trim(); if (!sec.header && !body) continue; if (sec.header && !body && !hasChild[i]) continue; // empty section, no children - if (sec.header) result.push('#'.repeat(sec.level) + ' ' + sec.header); + if (sec.header) result.push(`${'#'.repeat(sec.level)} ${sec.header}`); if (body) result.push(body); result.push(''); // blank line between sections } @@ -182,16 +202,20 @@ export function compressMarkdownTable(text: string): string { while (i < lines.length) { const line = lines[i]; - if ( - line.includes('|') && - i + 1 < lines.length && - TABLE_SEP_RE.test(lines[i + 1].trim()) - ) { - const headers = line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim()); + if (line.includes('|') && i + 1 < lines.length && TABLE_SEP_RE.test(lines[i + 1].trim())) { + const headers = line + .trim() + .replace(/^\||\|$/g, '') + .split('|') + .map((c) => c.trim()); i += 2; // skip header + separator const rows: string[][] = []; while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { - const cells = lines[i].trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim()); + const cells = lines[i] + .trim() + .replace(/^\||\|$/g, '') + .split('|') + .map((c) => c.trim()); rows.push(cells); i++; } @@ -199,7 +223,7 @@ export function compressMarkdownTable(text: string): string { if (headers.length >= 5) { // Wide tables: preserve rows without header/separator for (const row of rows) { - result.push('| ' + row.join(' | ') + ' |'); + result.push(`| ${row.join(' | ')} |`); } } else if (headers.length === 2) { // 2-column: key: value format @@ -258,7 +282,7 @@ function similarityRatio(a: string, b: string): number { intersection += Math.min(countA, bigramsB.get(bg) ?? 0); } - const total = (a.length - 1) + (b.length - 1); + const total = a.length - 1 + (b.length - 1); return total === 0 ? 0 : (2 * intersection) / total; } diff --git a/packages/compactor/src/tokens.ts b/packages/compactor/src/tokens.ts index a9be8e1..b0c6ac1 100644 --- a/packages/compactor/src/tokens.ts +++ b/packages/compactor/src/tokens.ts @@ -6,8 +6,7 @@ * ~1.5 chars/token for CJK characters. */ -const CJK_RANGE = - /[\u2E80-\u9FFF\uA000-\uA4CF\uAC00-\uD7AF\uF900-\uFAFF\u{20000}-\u{2FA1F}]/u; +const CJK_RANGE = /[\u2E80-\u9FFF\uA000-\uA4CF\uAC00-\uD7AF\uF900-\uFAFF\u{20000}-\u{2FA1F}]/u; /** * Estimate token count for a given text. @@ -59,7 +58,7 @@ export function truncateToTokenBudget(text: string, maxTokens: number): string { const charsPerToken = cjkRatio > 0.3 ? 1.5 + (1 - cjkRatio) * 2.5 : 4; // Initial estimate: slice by code points - let charLimit = Math.floor(maxTokens * charsPerToken); + const charLimit = Math.floor(maxTokens * charsPerToken); let truncated = codePoints.slice(0, charLimit).join(''); // Cut at last newline or space for clean boundary diff --git a/packages/compactor/src/types.ts b/packages/compactor/src/types.ts index e66135c..cd2f09d 100644 --- a/packages/compactor/src/types.ts +++ b/packages/compactor/src/types.ts @@ -5,7 +5,7 @@ * Core's Database satisfies CompactorStore without changes. */ -import type { Message, CompactionRecord, Provider } from '@tinyclaw/types'; +import type { CompactionRecord, Message, Provider } from '@tinyclaw/types'; // --------------------------------------------------------------------------- // CompactorStore — subset of Database used by compaction diff --git a/packages/compactor/tests/ccp.test.ts b/packages/compactor/tests/ccp.test.ts index dc68efb..85a4360 100644 --- a/packages/compactor/tests/ccp.test.ts +++ b/packages/compactor/tests/ccp.test.ts @@ -1,9 +1,6 @@ -import { describe, it, expect } from 'bun:test'; -import { - compressContext, - compressContextWithStats, -} from '../src/ccp.js'; +import { describe, expect, it } from 'bun:test'; import type { CcpLevel } from '../src/ccp.js'; +import { compressContext, compressContextWithStats } from '../src/ccp.js'; describe('compressContext', () => { const sampleText = diff --git a/packages/compactor/tests/compactor.test.ts b/packages/compactor/tests/compactor.test.ts index daaefde..35532f7 100644 --- a/packages/compactor/tests/compactor.test.ts +++ b/packages/compactor/tests/compactor.test.ts @@ -1,24 +1,35 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { existsSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { createDatabase } from '@tinyclaw/core'; -import { createCompactor } from '../src/compactor.js'; import type { Database } from '@tinyclaw/types'; -import { unlinkSync, existsSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; +import { createCompactor } from '../src/compactor.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function createTestDb(): { db: Database; path: string } { - const path = join(tmpdir(), `tinyclaw-test-compactor-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + const path = join( + tmpdir(), + `tinyclaw-test-compactor-${Date.now()}-${Math.random().toString(36).slice(2)}.db`, + ); const db = createDatabase(path); return { db, path }; } function cleanupDb(db: Database, path: string): void { - try { db.close(); } catch { /* ignore */ } - try { if (existsSync(path)) unlinkSync(path); } catch { /* ignore */ } + try { + db.close(); + } catch { + /* ignore */ + } + try { + if (existsSync(path)) unlinkSync(path); + } catch { + /* ignore */ + } } function createMockProvider(summaryResponse: string = 'Summary of conversation.') { @@ -28,8 +39,12 @@ function createMockProvider(summaryResponse: string = 'Summary of conversation.' async chat() { return { content: summaryResponse, role: 'assistant' as const }; }, - async isAvailable() { return true; }, - async chatStream() { return { content: summaryResponse, role: 'assistant' as const }; }, + async isAvailable() { + return true; + }, + async chatStream() { + return { content: summaryResponse, role: 'assistant' as const }; + }, }; } @@ -83,9 +98,9 @@ describe('createCompactor', () => { const result = await compactor.compactIfNeeded('user1', provider); expect(result).not.toBeNull(); - expect(result!.summary.l2).toContain('TypeScript'); - expect(result!.metrics.messagesBefore).toBe(10); - expect(result!.metrics.messagesKept).toBe(3); + expect(result?.summary.l2).toContain('TypeScript'); + expect(result?.metrics.messagesBefore).toBe(10); + expect(result?.metrics.messagesKept).toBe(3); }); it('saves compaction and allows retrieval', async () => { @@ -126,7 +141,7 @@ describe('createCompactor', () => { const result = await compactor.compactIfNeeded('user1', provider); expect(result).not.toBeNull(); - expect(result!.metrics.messagesKept).toBe(1); + expect(result?.metrics.messagesKept).toBe(1); }); it('returns metrics with compression ratio', async () => { @@ -139,9 +154,9 @@ describe('createCompactor', () => { const result = await compactor.compactIfNeeded('user1', provider); expect(result).not.toBeNull(); - expect(result!.metrics.compressionRatio).toBeGreaterThan(0); - expect(result!.metrics.compressionRatio).toBeLessThanOrEqual(1); - expect(result!.metrics.durationMs).toBeGreaterThanOrEqual(0); + expect(result?.metrics.compressionRatio).toBeGreaterThan(0); + expect(result?.metrics.compressionRatio).toBeLessThanOrEqual(1); + expect(result?.metrics.durationMs).toBeGreaterThanOrEqual(0); }); it('handles provider failure gracefully', async () => { @@ -149,9 +164,15 @@ describe('createCompactor', () => { const failingProvider = { id: 'fail', name: 'Failing Provider', - async chat() { throw new Error('LLM unavailable'); }, - async isAvailable() { return false; }, - async chatStream() { throw new Error('LLM unavailable'); }, + async chat() { + throw new Error('LLM unavailable'); + }, + async isAvailable() { + return false; + }, + async chatStream() { + throw new Error('LLM unavailable'); + }, }; for (let i = 0; i < 5; i++) { diff --git a/packages/compactor/tests/dedup.test.ts b/packages/compactor/tests/dedup.test.ts index 07d0ec8..0f2f932 100644 --- a/packages/compactor/tests/dedup.test.ts +++ b/packages/compactor/tests/dedup.test.ts @@ -1,10 +1,6 @@ -import { describe, it, expect } from 'bun:test'; -import { - computeShingles, - jaccardSimilarity, - deduplicateMessages, -} from '../src/dedup.js'; +import { describe, expect, it } from 'bun:test'; import type { Message } from '@tinyclaw/types'; +import { computeShingles, deduplicateMessages, jaccardSimilarity } from '../src/dedup.js'; describe('computeShingles', () => { it('returns empty set for empty text', () => { diff --git a/packages/compactor/tests/dictionary.test.ts b/packages/compactor/tests/dictionary.test.ts index ad87e18..b7fdd27 100644 --- a/packages/compactor/tests/dictionary.test.ts +++ b/packages/compactor/tests/dictionary.test.ts @@ -1,9 +1,9 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import { buildCodebook, + compressionStats, compressText, decompressText, - compressionStats, } from '../src/dictionary.js'; describe('buildCodebook', () => { diff --git a/packages/compactor/tests/optimizer.test.ts b/packages/compactor/tests/optimizer.test.ts index e18447c..eb5b81a 100644 --- a/packages/compactor/tests/optimizer.test.ts +++ b/packages/compactor/tests/optimizer.test.ts @@ -1,11 +1,11 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import { + compactBullets, + compressTableToKv, + minimizeWhitespace, optimizeTokens, stripBoldItalic, stripTrivialBackticks, - minimizeWhitespace, - compactBullets, - compressTableToKv, } from '../src/optimizer.js'; describe('stripBoldItalic', () => { diff --git a/packages/compactor/tests/rules.test.ts b/packages/compactor/tests/rules.test.ts index 6dd44b2..e863a26 100644 --- a/packages/compactor/tests/rules.test.ts +++ b/packages/compactor/tests/rules.test.ts @@ -1,15 +1,15 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import { - stripEmoji, - deduplicateLines, collapseWhitespace, - removeDecorativeLines, - normalizeCjkPunctuation, - removeEmptySections, compressMarkdownTable, - mergeSimilarBullets, + deduplicateLines, mergeShortBullets, + mergeSimilarBullets, + normalizeCjkPunctuation, preCompress, + removeDecorativeLines, + removeEmptySections, + stripEmoji, } from '../src/rules.js'; describe('stripEmoji', () => { @@ -203,7 +203,8 @@ describe('compressMarkdownTable', () => { describe('mergeSimilarBullets', () => { it('merges bullets with high similarity', () => { - const input = '- The quick brown fox jumps over the lazy dog\n- The quick brown fox jumps over the lazy cat'; + const input = + '- The quick brown fox jumps over the lazy dog\n- The quick brown fox jumps over the lazy cat'; const result = mergeSimilarBullets(input, 0.7); const lines = result.split('\n').filter((l) => l.trim()); expect(lines.length).toBe(1); // One was merged diff --git a/packages/compactor/tests/tiers.test.ts b/packages/compactor/tests/tiers.test.ts index 879d81f..f91e1a1 100644 --- a/packages/compactor/tests/tiers.test.ts +++ b/packages/compactor/tests/tiers.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import { generateTiers } from '../src/tiers.js'; import { estimateTokens } from '../src/tokens.js'; @@ -66,7 +66,8 @@ describe('generateTiers', () => { // L0 should be non-empty and contain high-priority lines expect(tiers.l0.length).toBeGreaterThan(0); - const hasIdentity = tiers.l0.includes('name') || tiers.l0.includes('decision') || tiers.l0.includes('Decision'); + const hasIdentity = + tiers.l0.includes('name') || tiers.l0.includes('decision') || tiers.l0.includes('Decision'); expect(hasIdentity).toBe(true); }); diff --git a/packages/compactor/tests/tokens.test.ts b/packages/compactor/tests/tokens.test.ts index d2d93f2..7c1c56a 100644 --- a/packages/compactor/tests/tokens.test.ts +++ b/packages/compactor/tests/tokens.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import { estimateTokens, truncateToTokenBudget } from '../src/tokens.js'; describe('estimateTokens', () => { diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index b2a9b0b..4ffde6c 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -8,15 +8,11 @@ * Non-sensitive data only — API keys and tokens belong in secrets-engine. */ +// Re-export shared types from @tinyclaw/types +export type { ConfigManagerConfig, ConfigManagerInterface } from '@tinyclaw/types'; // Core exports export { ConfigManager } from './manager.js'; export { createConfigTools } from './tools.js'; - // Types -export type { - TinyClawConfigData, -} from './types.js'; -export { TinyClawConfigSchema, CONFIG_DEFAULTS } from './types.js'; - -// Re-export shared types from @tinyclaw/types -export type { ConfigManagerConfig, ConfigManagerInterface } from '@tinyclaw/types'; +export type { TinyClawConfigData } from './types.js'; +export { CONFIG_DEFAULTS, TinyClawConfigSchema } from './types.js'; diff --git a/packages/config/src/manager.ts b/packages/config/src/manager.ts index 8d33fd6..fa368f3 100644 --- a/packages/config/src/manager.ts +++ b/packages/config/src/manager.ts @@ -24,15 +24,12 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; -import { ConfigEngine } from '@wgtechlabs/config-engine'; -import type { Unsubscribe, ChangeCallback, AnyChangeCallback } from '@wgtechlabs/config-engine'; import { logger } from '@tinyclaw/logger'; import type { ConfigManagerConfig, ConfigManagerInterface } from '@tinyclaw/types'; -import { - TinyClawConfigSchema, - CONFIG_DEFAULTS, -} from './types.js'; +import type { AnyChangeCallback, ChangeCallback, Unsubscribe } from '@wgtechlabs/config-engine'; +import { ConfigEngine } from '@wgtechlabs/config-engine'; import type { TinyClawConfigData } from './types.js'; +import { CONFIG_DEFAULTS, TinyClawConfigSchema } from './types.js'; export class ConfigManager implements ConfigManagerInterface { private engine: ConfigEngine; diff --git a/packages/config/src/tools.ts b/packages/config/src/tools.ts index 98e5636..cd416f1 100644 --- a/packages/config/src/tools.ts +++ b/packages/config/src/tools.ts @@ -21,10 +21,10 @@ export function createConfigTools(manager: ConfigManager): Tool[] { key: { type: 'string', description: - 'Dot-notation key to retrieve (e.g., "agent.name", "learning.minConfidence")' - } + 'Dot-notation key to retrieve (e.g., "agent.name", "learning.minConfidence")', + }, }, - required: ['key'] + required: ['key'], }, async execute(args: Record): Promise { if (typeof args.key !== 'string' || args.key.trim() === '') { @@ -43,7 +43,7 @@ export function createConfigTools(manager: ConfigManager): Tool[] { } catch (err) { return `Error getting config "${key}": ${(err as Error).message}`; } - } + }, }, { @@ -58,17 +58,16 @@ export function createConfigTools(manager: ConfigManager): Tool[] { properties: { key: { type: 'string', - description: - 'Dot-notation key to set (e.g., "agent.name", "learning.minConfidence")' + description: 'Dot-notation key to set (e.g., "agent.name", "learning.minConfidence")', }, value: { type: 'string', description: 'The value to set. For objects, pass a JSON string (e.g. \'{"key": "val"}\').' + - ' Booleans and numbers will be auto-detected from the string.' - } + ' Booleans and numbers will be auto-detected from the string.', + }, }, - required: ['key', 'value'] + required: ['key', 'value'], }, async execute(args: Record): Promise { if (typeof args.key !== 'string' || args.key.trim() === '') { @@ -88,9 +87,13 @@ export function createConfigTools(manager: ConfigManager): Tool[] { const trimmed = raw.trim(); if (trimmed === 'true') value = true; else if (trimmed === 'false') value = false; - else if (trimmed !== '' && !isNaN(Number(trimmed))) value = Number(trimmed); + else if (trimmed !== '' && !Number.isNaN(Number(trimmed))) value = Number(trimmed); else { - try { value = JSON.parse(trimmed); } catch { value = raw; } + try { + value = JSON.parse(trimmed); + } catch { + value = raw; + } } } @@ -103,7 +106,7 @@ export function createConfigTools(manager: ConfigManager): Tool[] { } catch (err) { return `Error setting config "${key}": ${(err as Error).message}`; } - } + }, }, { @@ -116,11 +119,10 @@ export function createConfigTools(manager: ConfigManager): Tool[] { properties: { key: { type: 'string', - description: - 'Dot-notation key to delete (e.g., "channels.telegram")' - } + description: 'Dot-notation key to delete (e.g., "channels.telegram")', + }, }, - required: ['key'] + required: ['key'], }, async execute(args: Record): Promise { if (typeof args.key !== 'string' || args.key.trim() === '') { @@ -139,7 +141,7 @@ export function createConfigTools(manager: ConfigManager): Tool[] { } catch (err) { return `Error deleting config "${key}": ${(err as Error).message}`; } - } + }, }, { @@ -151,7 +153,7 @@ export function createConfigTools(manager: ConfigManager): Tool[] { parameters: { type: 'object', properties: {}, - required: [] + required: [], }, async execute(): Promise { try { @@ -166,7 +168,7 @@ export function createConfigTools(manager: ConfigManager): Tool[] { } catch (err) { return `Error listing config: ${(err as Error).message}`; } - } - } + }, + }, ]; } diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 21f04cc..ed85fa6 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -6,8 +6,8 @@ * SQLite-backed storage for agent configuration. */ +import { DEFAULT_BASE_URL, DEFAULT_MODEL } from '@tinyclaw/core'; import { z } from 'zod'; -import { DEFAULT_MODEL, DEFAULT_BASE_URL } from '@tinyclaw/core'; // Re-export shared interfaces from @tinyclaw/types export type { ConfigManagerConfig, ConfigManagerInterface } from '@tinyclaw/types'; @@ -31,137 +31,187 @@ const ProviderEntrySchema = z.object({ * Full Tiny Claw configuration schema. * Validated on every `.set()` call via config-engine's built-in Zod support. */ -export const TinyClawConfigSchema = z.object({ - /** Owner authority — set during first-time claim flow */ - owner: z.object({ - /** The userId of the instance owner */ - ownerId: z.string().optional(), - /** SHA-256 hash of the persistent session token */ - sessionTokenHash: z.string().optional(), - /** Timestamp when ownership was claimed */ - claimedAt: z.number().optional(), - /** Base32 TOTP secret used for owner login */ - totpSecret: z.string().optional(), - /** Backup recovery code hashes (SHA-256) */ - backupCodeHashes: z.array(z.string()).optional(), - /** Number of remaining backup codes */ - backupCodesRemaining: z.number().int().nonnegative().optional(), - /** SHA-256 hash of the recovery token (required alongside backup codes) */ - recoveryTokenHash: z.string().optional(), - /** Timestamp when owner MFA was configured */ - mfaConfiguredAt: z.number().optional(), - }).optional(), - - /** Provider configurations keyed by provider name */ - providers: z.object({ - starterBrain: ProviderEntrySchema.optional(), - primary: ProviderEntrySchema.optional(), - }).passthrough().optional(), - - /** Channel configurations (telegram, discord, slack, etc.) */ - channels: z.object({ - telegram: z.object({ - enabled: z.boolean().optional(), - tokenRef: z.string().optional(), - }).optional(), - discord: z.object({ - enabled: z.boolean().optional(), - tokenRef: z.string().optional(), - }).optional(), - slack: z.object({ - enabled: z.boolean().optional(), - tokenRef: z.string().optional(), - }).optional(), - }).passthrough().optional(), - - /** Security settings */ - security: z.object({ - rateLimit: z.object({ - maxRequests: z.number().int().positive().optional(), - windowMs: z.number().int().positive().optional(), - }).optional(), - }).optional(), - - /** Learning engine settings */ - learning: z.object({ - enabled: z.boolean().optional(), - minConfidence: z.number().min(0).max(1).optional(), - }).optional(), - - /** Heartware settings */ - heartware: z.object({ - templateDir: z.string().optional(), - autoLoad: z.boolean().optional(), - }).optional(), - - /** Agent identity and workspace settings */ - agent: z.object({ - name: z.string().optional(), - identity: z.string().optional(), - workspace: z.string().optional(), - defaultModel: z.string().optional(), - }).optional(), - - /** Plugin system settings */ - plugins: z.object({ - enabled: z.array(z.string()).optional(), - }).optional(), - - /** Smart routing settings */ - routing: z.object({ - /** Maps query complexity tiers to provider IDs */ - tierMapping: z.object({ - simple: z.string().optional(), - moderate: z.string().optional(), - complex: z.string().optional(), - reasoning: z.string().optional(), - }).optional(), - }).optional(), - - /** Logging settings */ - logging: z.object({ - /** Log level: 'debug' | 'info' | 'warn' | 'error' | 'silent'. Default: 'info' */ - level: z.enum(['debug', 'info', 'warn', 'error', 'silent']).optional(), - }).optional(), - - /** Compaction settings */ - compaction: z.object({ - /** Message count threshold to trigger compaction. Default: 60 */ - threshold: z.number().int().positive().optional(), - /** Number of recent messages to keep after compaction. Default: 20 */ - keepRecent: z.number().int().positive().optional(), - /** Token budgets per summary tier */ - tierBudgets: z.object({ - l0: z.number().int().positive().optional(), - l1: z.number().int().positive().optional(), - l2: z.number().int().positive().optional(), - }).optional(), - /** Near-duplicate message detection */ - dedup: z.object({ - enabled: z.boolean().optional(), - similarityThreshold: z.number().min(0).max(1).optional(), - }).optional(), - /** Pre-compression rules before LLM summarization */ - preCompression: z.object({ - stripEmoji: z.boolean().optional(), - removeDuplicateLines: z.boolean().optional(), - }).optional(), - }).optional(), - - /** Nudge / proactive messaging settings */ - nudge: z.object({ - /** Master switch — disables all nudges when false. Default: true */ - enabled: z.boolean().optional(), - /** Quiet hours start (24h format, e.g. '22:00'). */ - quietHoursStart: z.string().regex(/^\d{2}:\d{2}$/).optional(), - /** Quiet hours end (24h format, e.g. '08:00'). */ - quietHoursEnd: z.string().regex(/^\d{2}:\d{2}$/).optional(), - /** Max nudges per hour. Default: 5 */ - maxPerHour: z.number().int().positive().optional(), - /** Categories to suppress (opt-out). */ - suppressedCategories: z.array(z.string()).optional(), - }).optional(), -}).passthrough(); +export const TinyClawConfigSchema = z + .object({ + /** Owner authority — set during first-time claim flow */ + owner: z + .object({ + /** The userId of the instance owner */ + ownerId: z.string().optional(), + /** SHA-256 hash of the persistent session token */ + sessionTokenHash: z.string().optional(), + /** Timestamp when ownership was claimed */ + claimedAt: z.number().optional(), + /** Base32 TOTP secret used for owner login */ + totpSecret: z.string().optional(), + /** Backup recovery code hashes (SHA-256) */ + backupCodeHashes: z.array(z.string()).optional(), + /** Number of remaining backup codes */ + backupCodesRemaining: z.number().int().nonnegative().optional(), + /** SHA-256 hash of the recovery token (required alongside backup codes) */ + recoveryTokenHash: z.string().optional(), + /** Timestamp when owner MFA was configured */ + mfaConfiguredAt: z.number().optional(), + }) + .optional(), + + /** Provider configurations keyed by provider name */ + providers: z + .object({ + starterBrain: ProviderEntrySchema.optional(), + primary: ProviderEntrySchema.optional(), + }) + .passthrough() + .optional(), + + /** Channel configurations (telegram, discord, slack, etc.) */ + channels: z + .object({ + telegram: z + .object({ + enabled: z.boolean().optional(), + tokenRef: z.string().optional(), + }) + .optional(), + discord: z + .object({ + enabled: z.boolean().optional(), + tokenRef: z.string().optional(), + }) + .optional(), + slack: z + .object({ + enabled: z.boolean().optional(), + tokenRef: z.string().optional(), + }) + .optional(), + }) + .passthrough() + .optional(), + + /** Security settings */ + security: z + .object({ + rateLimit: z + .object({ + maxRequests: z.number().int().positive().optional(), + windowMs: z.number().int().positive().optional(), + }) + .optional(), + }) + .optional(), + + /** Learning engine settings */ + learning: z + .object({ + enabled: z.boolean().optional(), + minConfidence: z.number().min(0).max(1).optional(), + }) + .optional(), + + /** Heartware settings */ + heartware: z + .object({ + templateDir: z.string().optional(), + autoLoad: z.boolean().optional(), + }) + .optional(), + + /** Agent identity and workspace settings */ + agent: z + .object({ + name: z.string().optional(), + identity: z.string().optional(), + workspace: z.string().optional(), + defaultModel: z.string().optional(), + }) + .optional(), + + /** Plugin system settings */ + plugins: z + .object({ + enabled: z.array(z.string()).optional(), + }) + .optional(), + + /** Smart routing settings */ + routing: z + .object({ + /** Maps query complexity tiers to provider IDs */ + tierMapping: z + .object({ + simple: z.string().optional(), + moderate: z.string().optional(), + complex: z.string().optional(), + reasoning: z.string().optional(), + }) + .optional(), + }) + .optional(), + + /** Logging settings */ + logging: z + .object({ + /** Log level: 'debug' | 'info' | 'warn' | 'error' | 'silent'. Default: 'info' */ + level: z.enum(['debug', 'info', 'warn', 'error', 'silent']).optional(), + }) + .optional(), + + /** Compaction settings */ + compaction: z + .object({ + /** Message count threshold to trigger compaction. Default: 60 */ + threshold: z.number().int().positive().optional(), + /** Number of recent messages to keep after compaction. Default: 20 */ + keepRecent: z.number().int().positive().optional(), + /** Token budgets per summary tier */ + tierBudgets: z + .object({ + l0: z.number().int().positive().optional(), + l1: z.number().int().positive().optional(), + l2: z.number().int().positive().optional(), + }) + .optional(), + /** Near-duplicate message detection */ + dedup: z + .object({ + enabled: z.boolean().optional(), + similarityThreshold: z.number().min(0).max(1).optional(), + }) + .optional(), + /** Pre-compression rules before LLM summarization */ + preCompression: z + .object({ + stripEmoji: z.boolean().optional(), + removeDuplicateLines: z.boolean().optional(), + }) + .optional(), + }) + .optional(), + + /** Nudge / proactive messaging settings */ + nudge: z + .object({ + /** Master switch — disables all nudges when false. Default: true */ + enabled: z.boolean().optional(), + /** Quiet hours start (24h format, e.g. '22:00'). */ + quietHoursStart: z + .string() + .regex(/^\d{2}:\d{2}$/) + .optional(), + /** Quiet hours end (24h format, e.g. '08:00'). */ + quietHoursEnd: z + .string() + .regex(/^\d{2}:\d{2}$/) + .optional(), + /** Max nudges per hour. Default: 5 */ + maxPerHour: z.number().int().positive().optional(), + /** Categories to suppress (opt-out). */ + suppressedCategories: z.array(z.string()).optional(), + }) + .optional(), + }) + .passthrough(); /** * Inferred TypeScript type from the Zod schema. diff --git a/packages/config/tests/manager.test.ts b/packages/config/tests/manager.test.ts index 03a12bf..015504a 100644 --- a/packages/config/tests/manager.test.ts +++ b/packages/config/tests/manager.test.ts @@ -7,8 +7,8 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdirSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { ConfigManager } from '../src/manager.js'; import { CONFIG_DEFAULTS } from '../src/types.js'; @@ -25,8 +25,16 @@ beforeEach(async () => { }); afterEach(() => { - try { manager.close(); } catch { /* ignore */ } - try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + try { + manager.close(); + } catch { + /* ignore */ + } + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } }); // ----------------------------------------------------------------------- diff --git a/packages/config/tests/tools.test.ts b/packages/config/tests/tools.test.ts index de6c637..98035c5 100644 --- a/packages/config/tests/tools.test.ts +++ b/packages/config/tests/tools.test.ts @@ -7,11 +7,11 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdirSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { Tool } from '@tinyclaw/types'; import { ConfigManager } from '../src/manager.js'; import { createConfigTools } from '../src/tools.js'; -import type { Tool } from '@tinyclaw/types'; let tmpDir: string; let manager: ConfigManager; @@ -35,8 +35,16 @@ beforeEach(async () => { }); afterEach(() => { - try { manager.close(); } catch { /* ignore */ } - try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + try { + manager.close(); + } catch { + /* ignore */ + } + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + /* ignore */ + } }); // ----------------------------------------------------------------------- diff --git a/packages/config/tests/types.test.ts b/packages/config/tests/types.test.ts index 7c02318..9dcd787 100644 --- a/packages/config/tests/types.test.ts +++ b/packages/config/tests/types.test.ts @@ -6,8 +6,8 @@ */ import { describe, expect, test } from 'bun:test'; -import { TinyClawConfigSchema, CONFIG_DEFAULTS } from '../src/types.js'; -import { DEFAULT_MODEL, DEFAULT_BASE_URL } from '@tinyclaw/core'; +import { DEFAULT_BASE_URL, DEFAULT_MODEL } from '@tinyclaw/core'; +import { CONFIG_DEFAULTS, TinyClawConfigSchema } from '../src/types.js'; // ----------------------------------------------------------------------- // Defaults validation diff --git a/packages/core/src/database.ts b/packages/core/src/database.ts index ab9cb00..504aea2 100644 --- a/packages/core/src/database.ts +++ b/packages/core/src/database.ts @@ -1,18 +1,29 @@ import { Database as BunDatabase, type SQLQueryBindings } from 'bun:sqlite'; -import type { Database, Message, CompactionRecord, SubAgentRecord, RoleTemplate, BackgroundTask, EpisodicRecord, TaskMetricRecord, BlackboardEntry, QueryTier } from '@tinyclaw/types'; -import { mkdirSync } from 'fs'; -import { dirname } from 'path'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; +import type { + BackgroundTask, + BlackboardEntry, + CompactionRecord, + Database, + EpisodicRecord, + Message, + QueryTier, + RoleTemplate, + SubAgentRecord, + TaskMetricRecord, +} from '@tinyclaw/types'; export function createDatabase(path: string): Database { // Ensure directory exists try { mkdirSync(dirname(path), { recursive: true }); - } catch (err) { + } catch (_err) { // Directory might already exist } - + const db = new BunDatabase(path); - + // Create tables db.exec(` CREATE TABLE IF NOT EXISTS messages ( @@ -151,19 +162,19 @@ export function createDatabase(path: string): Database { } catch { // Table already exists — safe to ignore } - + const saveMessageStmt = db.prepare(` INSERT INTO messages (user_id, role, content, created_at) VALUES (?, ?, ?, ?) `); - + const getHistoryStmt = db.prepare(` SELECT role, content FROM messages WHERE user_id = ? ORDER BY created_at DESC LIMIT ? `); - + const saveMemoryStmt = db.prepare(` INSERT INTO memory (user_id, key, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?) @@ -171,7 +182,7 @@ export function createDatabase(path: string): Database { value = excluded.value, updated_at = excluded.updated_at `); - + const getMemoryStmt = db.prepare(` SELECT key, value FROM memory WHERE user_id = ? @@ -325,12 +336,12 @@ export function createDatabase(path: string): Database { LIMIT ? `); - const updateEpisodicAccessStmt = db.prepare(` + const _updateEpisodicAccessStmt = db.prepare(` UPDATE episodic_memory SET access_count = access_count + 1, last_accessed_at = ? WHERE id = ? `); - const pruneEpisodicEventsStmt = db.prepare(` + const _pruneEpisodicEventsStmt = db.prepare(` DELETE FROM episodic_memory WHERE user_id = ? AND importance < ? AND access_count <= ? AND created_at < ? `); @@ -487,12 +498,12 @@ export function createDatabase(path: string): Database { saveMessage(userId: string, role: string, content: string): void { saveMessageStmt.run(userId, role, content, Date.now()); }, - + getHistory(userId: string, limit: number = 50): Message[] { - const rows = getHistoryStmt.all(userId, limit) as Array<{role: string, content: string}>; - return rows.reverse().map(row => ({ + const rows = getHistoryStmt.all(userId, limit) as Array<{ role: string; content: string }>; + return rows.reverse().map((row) => ({ role: row.role as Message['role'], - content: row.content + content: row.content, })); }, @@ -542,25 +553,34 @@ export function createDatabase(path: string): Database { const now = Date.now(); saveMemoryStmt.run(userId, key, value, now, now); }, - + getMemory(userId: string): Record { - const rows = getMemoryStmt.all(userId) as Array<{key: string, value: string}>; + const rows = getMemoryStmt.all(userId) as Array<{ key: string; value: string }>; const memory: Record = {}; for (const row of rows) { memory[row.key] = row.value; } return memory; }, - + // --- Sub-agents --- saveSubAgent(record: SubAgentRecord): void { saveSubAgentStmt.run( - record.id, record.userId, record.role, record.systemPrompt, - JSON.stringify(record.toolsGranted), record.tierPreference, - record.status, record.performanceScore, record.totalTasks, - record.successfulTasks, record.templateId, - record.createdAt, record.lastActiveAt, record.deletedAt, + record.id, + record.userId, + record.role, + record.systemPrompt, + JSON.stringify(record.toolsGranted), + record.tierPreference, + record.status, + record.performanceScore, + record.totalTasks, + record.successfulTasks, + record.templateId, + record.createdAt, + record.lastActiveAt, + record.deletedAt, ); }, @@ -584,17 +604,39 @@ export function createDatabase(path: string): Database { const fields: string[] = []; const values: unknown[] = []; - if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); } - if (updates.performanceScore !== undefined) { fields.push('performance_score = ?'); values.push(updates.performanceScore); } - if (updates.totalTasks !== undefined) { fields.push('total_tasks = ?'); values.push(updates.totalTasks); } - if (updates.successfulTasks !== undefined) { fields.push('successful_tasks = ?'); values.push(updates.successfulTasks); } - if (updates.lastActiveAt !== undefined) { fields.push('last_active_at = ?'); values.push(updates.lastActiveAt); } - if (updates.deletedAt !== undefined) { fields.push('deleted_at = ?'); values.push(updates.deletedAt); } - if ('deletedAt' in updates && updates.deletedAt === null) { fields.push('deleted_at = NULL'); } + if (updates.status !== undefined) { + fields.push('status = ?'); + values.push(updates.status); + } + if (updates.performanceScore !== undefined) { + fields.push('performance_score = ?'); + values.push(updates.performanceScore); + } + if (updates.totalTasks !== undefined) { + fields.push('total_tasks = ?'); + values.push(updates.totalTasks); + } + if (updates.successfulTasks !== undefined) { + fields.push('successful_tasks = ?'); + values.push(updates.successfulTasks); + } + if (updates.lastActiveAt !== undefined) { + fields.push('last_active_at = ?'); + values.push(updates.lastActiveAt); + } + if (updates.deletedAt !== undefined) { + fields.push('deleted_at = ?'); + values.push(updates.deletedAt); + } + if ('deletedAt' in updates && updates.deletedAt === null) { + fields.push('deleted_at = NULL'); + } if (fields.length === 0) return; values.push(id); - db.prepare(`UPDATE sub_agents SET ${fields.join(', ')} WHERE id = ?`).run(...values as SQLQueryBindings[]); + db.prepare(`UPDATE sub_agents SET ${fields.join(', ')} WHERE id = ?`).run( + ...(values as SQLQueryBindings[]), + ); }, deleteExpiredSubAgents(beforeTimestamp: number): number { @@ -611,10 +653,17 @@ export function createDatabase(path: string): Database { saveRoleTemplate(template: RoleTemplate): void { saveRoleTemplateStmt.run( - template.id, template.userId, template.name, template.roleDescription, - JSON.stringify(template.defaultTools), template.defaultTier, - template.timesUsed, template.avgPerformance, - JSON.stringify(template.tags), template.createdAt, template.updatedAt, + template.id, + template.userId, + template.name, + template.roleDescription, + JSON.stringify(template.defaultTools), + template.defaultTier, + template.timesUsed, + template.avgPerformance, + JSON.stringify(template.tags), + template.createdAt, + template.updatedAt, ); }, @@ -632,18 +681,44 @@ export function createDatabase(path: string): Database { const fields: string[] = []; const values: unknown[] = []; - if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); } - if (updates.roleDescription !== undefined) { fields.push('role_description = ?'); values.push(updates.roleDescription); } - if (updates.defaultTools !== undefined) { fields.push('default_tools = ?'); values.push(JSON.stringify(updates.defaultTools)); } - if (updates.defaultTier !== undefined) { fields.push('default_tier = ?'); values.push(updates.defaultTier); } - if (updates.timesUsed !== undefined) { fields.push('times_used = ?'); values.push(updates.timesUsed); } - if (updates.avgPerformance !== undefined) { fields.push('avg_performance = ?'); values.push(updates.avgPerformance); } - if (updates.tags !== undefined) { fields.push('tags = ?'); values.push(JSON.stringify(updates.tags)); } - if (updates.updatedAt !== undefined) { fields.push('updated_at = ?'); values.push(updates.updatedAt); } + if (updates.name !== undefined) { + fields.push('name = ?'); + values.push(updates.name); + } + if (updates.roleDescription !== undefined) { + fields.push('role_description = ?'); + values.push(updates.roleDescription); + } + if (updates.defaultTools !== undefined) { + fields.push('default_tools = ?'); + values.push(JSON.stringify(updates.defaultTools)); + } + if (updates.defaultTier !== undefined) { + fields.push('default_tier = ?'); + values.push(updates.defaultTier); + } + if (updates.timesUsed !== undefined) { + fields.push('times_used = ?'); + values.push(updates.timesUsed); + } + if (updates.avgPerformance !== undefined) { + fields.push('avg_performance = ?'); + values.push(updates.avgPerformance); + } + if (updates.tags !== undefined) { + fields.push('tags = ?'); + values.push(JSON.stringify(updates.tags)); + } + if (updates.updatedAt !== undefined) { + fields.push('updated_at = ?'); + values.push(updates.updatedAt); + } if (fields.length === 0) return; values.push(id); - db.prepare(`UPDATE role_templates SET ${fields.join(', ')} WHERE id = ?`).run(...values as SQLQueryBindings[]); + db.prepare(`UPDATE role_templates SET ${fields.join(', ')} WHERE id = ?`).run( + ...(values as SQLQueryBindings[]), + ); }, deleteRoleTemplate(id: string): void { @@ -654,13 +729,24 @@ export function createDatabase(path: string): Database { saveBackgroundTask(record: BackgroundTask): void { saveBackgroundTaskStmt.run( - record.id, record.userId, record.agentId, record.taskDescription, - record.status, record.result, record.startedAt, - record.completedAt, record.deliveredAt, + record.id, + record.userId, + record.agentId, + record.taskDescription, + record.status, + record.result, + record.startedAt, + record.completedAt, + record.deliveredAt, ); }, - updateBackgroundTask(id: string, status: string, result: string | null, completedAt: number | null): void { + updateBackgroundTask( + id: string, + status: string, + result: string | null, + completedAt: number | null, + ): void { updateBackgroundTaskStmt.run(status, result, completedAt, id); }, @@ -693,13 +779,23 @@ export function createDatabase(path: string): Database { saveEpisodicEvent(record: EpisodicRecord): void { saveEpisodicEventStmt.run( - record.id, record.userId, record.eventType, record.content, - record.outcome, record.importance, record.accessCount, - record.createdAt, record.lastAccessedAt, + record.id, + record.userId, + record.eventType, + record.content, + record.outcome, + record.importance, + record.accessCount, + record.createdAt, + record.lastAccessedAt, ); // Also index in FTS5 for full-text search const tags = `${record.eventType} ${record.userId}`; - insertFTSStmt.run(record.id, record.content + (record.outcome ? ' ' + record.outcome : ''), tags); + insertFTSStmt.run( + record.id, + record.content + (record.outcome ? ` ${record.outcome}` : ''), + tags, + ); }, getEpisodicEvent(id: string): EpisodicRecord | null { @@ -716,15 +812,32 @@ export function createDatabase(path: string): Database { const fields: string[] = []; const values: unknown[] = []; - if (updates.importance !== undefined) { fields.push('importance = ?'); values.push(updates.importance); } - if (updates.accessCount !== undefined) { fields.push('access_count = ?'); values.push(updates.accessCount); } - if (updates.lastAccessedAt !== undefined) { fields.push('last_accessed_at = ?'); values.push(updates.lastAccessedAt); } - if (updates.content !== undefined) { fields.push('content = ?'); values.push(updates.content); } - if (updates.outcome !== undefined) { fields.push('outcome = ?'); values.push(updates.outcome); } + if (updates.importance !== undefined) { + fields.push('importance = ?'); + values.push(updates.importance); + } + if (updates.accessCount !== undefined) { + fields.push('access_count = ?'); + values.push(updates.accessCount); + } + if (updates.lastAccessedAt !== undefined) { + fields.push('last_accessed_at = ?'); + values.push(updates.lastAccessedAt); + } + if (updates.content !== undefined) { + fields.push('content = ?'); + values.push(updates.content); + } + if (updates.outcome !== undefined) { + fields.push('outcome = ?'); + values.push(updates.outcome); + } if (fields.length === 0) return; values.push(id); - db.prepare(`UPDATE episodic_memory SET ${fields.join(', ')} WHERE id = ?`).run(...values as SQLQueryBindings[]); + db.prepare(`UPDATE episodic_memory SET ${fields.join(', ')} WHERE id = ?`).run( + ...(values as SQLQueryBindings[]), + ); }, deleteEpisodicEvents(ids: string[]): void { @@ -737,10 +850,15 @@ export function createDatabase(path: string): Database { } }, - searchEpisodicFTS(query: string, userId: string, limit = 20): Array<{ id: string; rank: number }> { + searchEpisodicFTS( + query: string, + userId: string, + limit = 20, + ): Array<{ id: string; rank: number }> { if (!query.trim()) return []; try { - const rows = db.prepare(` + const rows = db + .prepare(` SELECT f.id, rank FROM memory_fts f JOIN episodic_memory e ON e.id = f.id @@ -748,7 +866,8 @@ export function createDatabase(path: string): Database { AND e.user_id = ? ORDER BY rank LIMIT ? - `).all(query, userId, limit) as Array<{ id: string; rank: number }>; + `) + .all(query, userId, limit) as Array<{ id: string; rank: number }>; return rows; } catch { // FTS5 match can fail on malformed queries — graceful fallback @@ -758,25 +877,34 @@ export function createDatabase(path: string): Database { decayEpisodicImportance(userId: string, olderThanDays: number, decayFactor: number): number { const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000; - const result = db.prepare(` + const result = db + .prepare(` UPDATE episodic_memory SET importance = importance * ? WHERE user_id = ? AND last_accessed_at < ? AND importance > 0.05 - `).run(decayFactor, userId, cutoff); + `) + .run(decayFactor, userId, cutoff); return result.changes; }, - pruneEpisodicEvents(userId: string, maxImportance: number, maxAccessCount: number, olderThanMs: number): number { + pruneEpisodicEvents( + userId: string, + maxImportance: number, + maxAccessCount: number, + olderThanMs: number, + ): number { const cutoff = Date.now() - olderThanMs; // First get IDs to prune (so we can clean FTS too) - const rows = db.prepare(` + const rows = db + .prepare(` SELECT id FROM episodic_memory WHERE user_id = ? AND importance < ? AND access_count <= ? AND created_at < ? - `).all(userId, maxImportance, maxAccessCount, cutoff) as Array<{ id: string }>; + `) + .all(userId, maxImportance, maxAccessCount, cutoff) as Array<{ id: string }>; if (rows.length === 0) return 0; - const ids = rows.map(r => r.id); + const ids = rows.map((r) => r.id); const placeholders = ids.map(() => '?').join(', '); db.prepare(`DELETE FROM episodic_memory WHERE id IN (${placeholders})`).run(...ids); for (const id of ids) { @@ -789,8 +917,13 @@ export function createDatabase(path: string): Database { saveTaskMetric(record: TaskMetricRecord): void { saveTaskMetricStmt.run( - record.id, record.userId, record.taskType, record.tier, - record.durationMs, record.iterations, record.success ? 1 : 0, + record.id, + record.userId, + record.taskType, + record.tier, + record.durationMs, + record.iterations, + record.success ? 1 : 0, record.createdAt, ); }, @@ -804,9 +937,17 @@ export function createDatabase(path: string): Database { saveBlackboardEntry(entry: BlackboardEntry): void { saveBlackboardEntryStmt.run( - entry.id, entry.userId, entry.problemId, entry.problemText, - entry.agentId, entry.agentRole, entry.proposal, entry.confidence, - entry.synthesis, entry.status, entry.createdAt, + entry.id, + entry.userId, + entry.problemId, + entry.problemText, + entry.agentId, + entry.agentRole, + entry.proposal, + entry.confidence, + entry.synthesis, + entry.status, + entry.createdAt, ); }, @@ -831,14 +972,16 @@ export function createDatabase(path: string): Database { cleanupBlackboard(olderThanMs: number): number { const cutoff = Date.now() - olderThanMs; - const result = db.prepare(` + const result = db + .prepare(` DELETE FROM blackboard WHERE status = 'resolved' AND created_at < ? - `).run(cutoff); + `) + .run(cutoff); return result.changes; }, close(): void { db.close(); - } + }, }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3d20616..71e4bdc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,60 +1,55 @@ // Database export { createDatabase } from './database.js'; - -// Agent loop -export { agentLoop } from './loop.js'; - // Built-in Ollama provider (default LLM) export { createOllamaProvider } from './llm.js'; - -// Model constants — single source of truth -export { - DEFAULT_MODEL, - DEFAULT_BASE_URL, - DEFAULT_PROVIDER, - BUILTIN_MODELS, - BUILTIN_MODEL_TAGS, -} from './models.js'; -export type { BuiltinModelTag } from './models.js'; - +// Agent loop +export { agentLoop } from './loop.js'; // Shared onboarding messages — single source of truth export { - SECURITY_WARNING_TITLE, - SECURITY_WARNING_BODY, + BACKUP_CODES_HINT, + BACKUP_CODES_INTRO, + defaultModelNote, + RECOVERY_TOKEN_HINT, + SECURITY_CONFIRM, SECURITY_LICENSE, - SECURITY_WARRANTY, - SECURITY_SAFETY_TITLE, SECURITY_SAFETY_PRACTICES, - SECURITY_CONFIRM, - defaultModelNote, - TOTP_SETUP_TITLE, + SECURITY_SAFETY_TITLE, + SECURITY_WARNING_BODY, + SECURITY_WARNING_TITLE, + SECURITY_WARRANTY, TOTP_SETUP_BODY, - BACKUP_CODES_INTRO, - BACKUP_CODES_HINT, - RECOVERY_TOKEN_HINT, + TOTP_SETUP_TITLE, } from './messages.js'; +export type { BuiltinModelTag } from './models.js'; +// Model constants — single source of truth +export { + BUILTIN_MODEL_TAGS, + BUILTIN_MODELS, + DEFAULT_BASE_URL, + DEFAULT_MODEL, + DEFAULT_PROVIDER, +} from './models.js'; // Owner authority — shared crypto utilities export { - generateRecoveryToken, + BACKUP_CODE_LENGTH, + BACKUP_CODES_COUNT, + createTotpUri, generateBackupCode, generateBackupCodes, - generateTotpSecret, - createTotpUri, - generateTotpCode, - verifyTotpCode, - sha256, + generateRecoveryToken, generateSessionToken, - BACKUP_CODES_COUNT, - BACKUP_CODE_LENGTH, + generateTotpCode, + generateTotpSecret, RECOVERY_TOKEN_LENGTH, + sha256, + verifyTotpCode, } from './owner-auth.js'; - +export type { UpdateInfo, UpdateRuntime } from './update-checker.js'; // Update checker — npm registry polling + system prompt context export { - checkForUpdate, buildUpdateContext, + checkForUpdate, detectRuntime, isNewerVersion, } from './update-checker.js'; -export type { UpdateInfo, UpdateRuntime } from './update-checker.js'; diff --git a/packages/core/src/llm.ts b/packages/core/src/llm.ts index 346d9a6..23595af 100644 --- a/packages/core/src/llm.ts +++ b/packages/core/src/llm.ts @@ -1,6 +1,6 @@ import { logger } from '@tinyclaw/logger'; -import type { Provider, Message, LLMResponse, Tool, ToolCall } from '@tinyclaw/types'; import type { SecretsManager } from '@tinyclaw/secrets'; +import type { LLMResponse, Message, Provider, Tool, ToolCall } from '@tinyclaw/types'; import { DEFAULT_MODEL } from './models.js'; export interface OllamaConfig { @@ -15,9 +15,10 @@ export interface OllamaConfig { // --------------------------------------------------------------------------- /** Convert internal Tool[] to the Ollama API tools format. */ -function toOllamaTools( - tools: Tool[], -): { type: 'function'; function: { name: string; description: string; parameters: Record } }[] { +function toOllamaTools(tools: Tool[]): { + type: 'function'; + function: { name: string; description: string; parameters: Record }; +}[] { return tools.map((t) => ({ type: 'function' as const, function: { @@ -99,11 +100,11 @@ export function createOllamaProvider(config: OllamaConfig): Provider { // Derive a human-readable short name from the model tag const shortName = model.split(':')[0]; - + return { id: 'ollama-cloud', name: `Ollama Cloud (${shortName})`, - + async chat(messages: Message[], tools?: Tool[]): Promise { try { // Resolve API key: explicit value or secrets-engine lookup @@ -111,7 +112,7 @@ export function createOllamaProvider(config: OllamaConfig): Provider { if (!apiKey) { throw new Error( 'No API key available for Ollama. ' + - 'Store one with: store_secret key="provider.ollama.apiKey" value="sk-..."' + 'Store one with: store_secret key="provider.ollama.apiKey" value="sk-..."', ); } @@ -129,22 +130,22 @@ export function createOllamaProvider(config: OllamaConfig): Provider { const response = await fetch(`${baseUrl}/api/chat`, { method: 'POST', headers: { - 'Authorization': `Bearer ${apiKey}`, + Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); - + if (!response.ok) { const errorBody = await response.text().catch(() => ''); throw new Error( `Ollama API error: ${response.status} ${response.statusText}` + - (errorBody ? ` — ${errorBody}` : '') + (errorBody ? ` — ${errorBody}` : ''), ); } - + const data = await response.json(); - + // Debug: log raw API response to understand its structure logger.debug('Raw API response:', JSON.stringify(data).slice(0, 500)); @@ -164,9 +165,9 @@ export function createOllamaProvider(config: OllamaConfig): Provider { // 2. Text content const content = msg?.content || - data.response || // Simple format - data.content || // Direct content - data.text || // Text format + data.response || // Simple format + data.content || // Direct content + data.text || // Text format ''; if (content) { @@ -195,7 +196,7 @@ export function createOllamaProvider(config: OllamaConfig): Provider { throw error; } }, - + async isAvailable(): Promise { try { const apiKey = config.apiKey ?? (await config.secrets?.resolveProviderKey('ollama')); @@ -207,7 +208,7 @@ export function createOllamaProvider(config: OllamaConfig): Provider { const response = await fetch(`${baseUrl}/api/chat`, { method: 'POST', headers: { - 'Authorization': `Bearer ${apiKey}`, + Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ @@ -234,6 +235,6 @@ export function createOllamaProvider(config: OllamaConfig): Provider { } return false; } - } + }, }; } diff --git a/packages/core/src/loop.ts b/packages/core/src/loop.ts index b366a25..9223eca 100644 --- a/packages/core/src/loop.ts +++ b/packages/core/src/loop.ts @@ -1,8 +1,14 @@ -import type { AgentContext, Message, ToolCall, PendingApproval, ShieldEvent, ShieldDecision } from '@tinyclaw/types'; -import { OWNER_ONLY_TOOLS, isOwner } from '@tinyclaw/types'; -import { logger } from '@tinyclaw/logger'; import { DELEGATION_HANDBOOK, DELEGATION_TOOL_NAMES } from '@tinyclaw/delegation'; +import { logger } from '@tinyclaw/logger'; import { SHELL_TOOL_NAMES } from '@tinyclaw/shell'; +import type { + AgentContext, + Message, + PendingApproval, + ShieldEvent, + ToolCall, +} from '@tinyclaw/types'; +import { isOwner, OWNER_ONLY_TOOLS } from '@tinyclaw/types'; import { BUILTIN_MODEL_TAGS } from './models.js'; /** @@ -10,9 +16,7 @@ import { BUILTIN_MODEL_TAGS } from './models.js'; * bypass shield's `require_approval` action to avoid double-approval UX. * Shield `block` is still honored for these tools. */ -const SELF_GATED_TOOLS: ReadonlySet = new Set([ - ...SHELL_TOOL_NAMES, -]); +const SELF_GATED_TOOLS: ReadonlySet = new Set([...SHELL_TOOL_NAMES]); // --------------------------------------------------------------------------- // Text Sanitization — strip em-dashes from LLM output @@ -28,12 +32,14 @@ function stripDashes(text: string): string { // Replace "—" (unspaced em-dash) with ", " // Replace " – " (spaced en-dash) with ", " // Replace "–" (unspaced en-dash) with ", " - return text - .replace(/\s*—\s*/g, ', ') - .replace(/\s*–\s*/g, ', ') - // Clean up double commas or comma-period that may result - .replace(/,\s*,/g, ',') - .replace(/,\s*\./g, '.'); + return ( + text + .replace(/\s*—\s*/g, ', ') + .replace(/\s*–\s*/g, ', ') + // Clean up double commas or comma-period that may result + .replace(/,\s*,/g, ',') + .replace(/,\s*\./g, '.') + ); } // --------------------------------------------------------------------------- @@ -70,13 +76,13 @@ const INJECTION_PATTERNS: RegExp[] = [ * These are recognizable by the LLM as content boundaries. */ const UNTRUSTED_BOUNDARY_START = '<<>>'; -const UNTRUSTED_BOUNDARY_END = '<<>>'; +const UNTRUSTED_BOUNDARY_END = '<<>>'; /** * Check if a message contains prompt injection patterns. */ function containsInjectionPatterns(text: string): boolean { - return INJECTION_PATTERNS.some(pattern => pattern.test(text)); + return INJECTION_PATTERNS.some((pattern) => pattern.test(text)); } /** @@ -86,7 +92,7 @@ function containsInjectionPatterns(text: string): boolean { const INTERNAL_USER_PREFIXES = ['pulse:', 'companion:', 'system:']; function isInternalUser(userId: string): boolean { - return INTERNAL_USER_PREFIXES.some(prefix => userId.startsWith(prefix)); + return INTERNAL_USER_PREFIXES.some((prefix) => userId.startsWith(prefix)); } /** @@ -228,7 +234,11 @@ const OWNER_ONLY_REFUSAL = * Check whether a tool call is allowed for the given user. * Returns null if allowed, or a refusal message if blocked. */ -function checkToolAuthority(toolName: string, userId: string, ownerId: string | undefined): string | null { +function checkToolAuthority( + toolName: string, + userId: string, + ownerId: string | undefined, +): string | null { if (!ownerId) return null; // No owner set yet — allow everything (pre-claim) if (isOwner(userId, ownerId)) return null; // Owner can do anything if (OWNER_ONLY_TOOLS.has(toolName)) return OWNER_ONLY_REFUSAL; @@ -239,11 +249,9 @@ function checkToolAuthority(toolName: string, userId: string, ownerId: string | * Safely extract a summary from a delegate_tasks batch. * Returns the joined role names and the task count. */ -function extractBatchTasksSummary( - tasks: unknown, -): { roles: string; count: number } { +function extractBatchTasksSummary(tasks: unknown): { roles: string; count: number } { const arr: Array> = Array.isArray(tasks) ? tasks : []; - const roles = arr.map(t => String(t.role || 'Sub-agent')).join(', '); + const roles = arr.map((t) => String(t.role || 'Sub-agent')).join(', '); return { roles, count: arr.length }; } @@ -297,23 +305,22 @@ function emitDelegationComplete( // delegate_to_existing: "... () [task: ] ..." // delegate_tasks: multi-line output with multiple agent:/task: pairs const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; - const allUUIDs = result.match(UUID_RE) || []; + const _allUUIDs = result.match(UUID_RE) || []; // For delegate_task: "agent: , task: " → agentId is matched first, taskId second // For delegate_background: "[id: ]\nSub-agent: Role ()" → taskId first, agentId second // For delegate_to_existing: "() [task: ]" → agentId first, taskId second // Heuristic: look for labelled UUIDs first, then fall back to positional - const agentIdMatch = result.match(/\bagent:\s*([0-9a-f-]{36})/i) - || result.match(/\(([0-9a-f-]{36})\)/); - const taskIdMatch = result.match(/\btask:\s*([0-9a-f-]{36})/i) - || result.match(/\bid:\s*([0-9a-f-]{36})/i); + const agentIdMatch = + result.match(/\bagent:\s*([0-9a-f-]{36})/i) || result.match(/\(([0-9a-f-]{36})\)/); + const taskIdMatch = + result.match(/\btask:\s*([0-9a-f-]{36})/i) || result.match(/\bid:\s*([0-9a-f-]{36})/i); const agentId = agentIdMatch?.[1]?.trim(); const taskId = taskIdMatch?.[1]?.trim(); // delegate_tasks (batch) — summarise all dispatched tasks - const batchSummary = toolCall.name === 'delegate_tasks' - ? extractBatchTasksSummary(args.tasks) - : null; + const batchSummary = + toolCall.name === 'delegate_tasks' ? extractBatchTasksSummary(args.tasks) : null; const role = batchSummary ? batchSummary.roles : String(args.role || args.agent_id || ''); const taskDesc = batchSummary ? `${batchSummary.count} tasks` : String(args.task || ''); @@ -360,13 +367,13 @@ function extractToolCallFromText(text: string): ToolCall | null { return { id: crypto.randomUUID(), name: toolName, - arguments: normalizeToolArguments(rest) + arguments: normalizeToolArguments(rest), }; } function summarizeToolResults( toolCalls: ToolCall[], - toolResults: Array<{ id: string; result: string }> + toolResults: Array<{ id: string; result: string }>, ): string { const summaries: string[] = []; @@ -382,9 +389,9 @@ function summarizeToolResults( // Write operations - confirm success if (name === 'heartware_write') { - summaries.push(filename - ? `Done! I've saved that to ${filename}. ✓` - : 'Done! Saved successfully. ✓'); + summaries.push( + filename ? `Done! I've saved that to ${filename}. ✓` : 'Done! Saved successfully. ✓', + ); continue; } @@ -414,7 +421,7 @@ function summarizeToolResults( // Read operations - summarize what was found if (name === 'heartware_read') { - const lines = result.split('\n').filter(l => l.trim()).length; + const lines = result.split('\n').filter((l) => l.trim()).length; summaries.push(`Read ${filename || 'file'} (${lines} lines).`); continue; } @@ -426,7 +433,7 @@ function summarizeToolResults( } if (name === 'heartware_list') { - const fileCount = result.split('\n').filter(l => l.trim()).length; + const fileCount = result.split('\n').filter((l) => l.trim()).length; summaries.push(`Found ${fileCount} files.`); continue; } @@ -444,15 +451,20 @@ function summarizeToolResults( return summaries.join(' '); } -function getBaseSystemPrompt(heartwareContext?: string, modelInfo?: { model: string; provider: string }, ownerId?: string): string { +function getBaseSystemPrompt( + heartwareContext?: string, + modelInfo?: { model: string; provider: string }, + ownerId?: string, +): string { let prompt = `You are Tiny Claw 🐜, a helpful AI companion. You are small but mighty, focused, efficient, and always learning. ## Owner Authority -${ownerId - ? `Your owner's userId is \`${ownerId}\`. You are loyal to this person. They set you up, and you serve them. +${ + ownerId + ? `Your owner's userId is \`${ownerId}\`. You are loyal to this person. They set you up, and you serve them. **Rules:** - **Owner messages** (userId = \`${ownerId}\`): Full access. Follow their commands, use any tool, modify config/heartware/secrets. @@ -460,8 +472,9 @@ ${ownerId - You may chat with anyone, answer questions, and be helpful, but you only **take orders** from your owner. - When a friend asks you to remember something about them, you may add it to FRIENDS.md (this is allowed). - FRIEND.md is about your owner. FRIENDS.md is about everyone else you meet.` - : `No owner has been claimed yet. The first person to complete the claim flow becomes your owner. -Until then, treat everyone as a potential owner and allow all actions.`} + : `No owner has been claimed yet. The first person to complete the claim flow becomes your owner. +Until then, treat everyone as a potential owner and allow all actions.` +} ## Current Runtime - **Model:** ${modelInfo?.model ?? 'unknown'} @@ -501,7 +514,7 @@ To update your identity (like nickname): {"action": "identity_update", "name": "Anty", "tagline": "Your small-but-mighty AI companion"} To switch to a different built-in model: -{"action": "builtin_model_switch", "model": "${BUILTIN_MODEL_TAGS.find(t => t !== modelInfo?.model) ?? BUILTIN_MODEL_TAGS[1]}"} +{"action": "builtin_model_switch", "model": "${BUILTIN_MODEL_TAGS.find((t) => t !== modelInfo?.model) ?? BUILTIN_MODEL_TAGS[1]}"} **Available tools:** - heartware_read, heartware_write, heartware_list, heartware_search @@ -583,9 +596,10 @@ export async function agentLoop( message: string, userId: string, context: AgentContext, - rawOnStream?: (event: import('@tinyclaw/types').StreamEvent) => void + rawOnStream?: (event: import('@tinyclaw/types').StreamEvent) => void, ): Promise { - const { db, provider, learning, tools, heartwareContext, shield, modelName, providerName } = context; + const { db, provider, learning, tools, heartwareContext, shield, modelName, providerName } = + context; // Wrap onStream to automatically strip em-dashes from all text events const onStream: typeof rawOnStream = rawOnStream @@ -599,9 +613,10 @@ export async function agentLoop( : undefined; // Build model info for system prompt injection - const modelInfo = modelName || providerName - ? { model: modelName ?? 'unknown', provider: providerName ?? 'unknown' } - : undefined; + const modelInfo = + modelName || providerName + ? { model: modelName ?? 'unknown', provider: providerName ?? 'unknown' } + : undefined; // --------------------------------------------------------------------------- // Shield — check for pending approval from a previous turn @@ -609,7 +624,6 @@ export async function agentLoop( const pending = getPendingApproval(userId); if (pending) { - // Interpret the user's natural-language answer. // We inject a focused system prompt and let the LLM classify the response. const classifyMessages: Message[] = [ @@ -636,7 +650,7 @@ export async function agentLoop( if (/^\s*APPROVED\s*$/.test(verdict)) { // Execute the previously blocked tool call logger.info('Shield: approval granted', { tool: pending.toolCall.name, userId }); - const tool = tools.find(t => t.name === pending.toolCall.name); + const tool = tools.find((t) => t.name === pending.toolCall.name); if (tool) { try { // Emit delegation SSE events so the sidebar updates immediately @@ -664,7 +678,10 @@ export async function agentLoop( } } else { // Tool no longer registered — inform the user and persist the event - logger.error('Shield: approved tool no longer available', { tool: pending.toolCall.name, userId }); + logger.error('Shield: approved tool no longer available', { + tool: pending.toolCall.name, + userId, + }); const errorMsg = `Approved, but tool **${pending.toolCall.name}** is no longer available. It may have been unregistered.`; if (onStream) { onStream({ type: 'text', content: errorMsg }); @@ -686,7 +703,10 @@ export async function agentLoop( return responseText; } else { // UNCLEAR — re-ask: push the entry back to the front of the queue - logger.info('Shield: approval response unclear, re-asking', { tool: pending.toolCall.name, userId }); + logger.info('Shield: approval response unclear, re-asking', { + tool: pending.toolCall.name, + userId, + }); const queue = pendingApprovals.get(userId) ?? []; pending.createdAt = Date.now(); queue.unshift(pending); @@ -730,7 +750,7 @@ export async function agentLoop( // Load context — prepend compaction summary if one exists const compactionSummary = context.compactor ? context.compactor.getLatestSummary(userId) - : db.getLatestCompaction(userId)?.summary ?? null; + : (db.getLatestCompaction(userId)?.summary ?? null); const rawHistory = db.getHistory(userId, 20); const history: Message[] = []; @@ -789,27 +809,33 @@ export async function agentLoop( ...history, { role: 'user', content: sanitizedMessage }, ]; - + // Agent loop (with tool execution if needed) let jsonToolReplies = 0; let sentToolProgress = false; for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) { const response = await provider.chat(messages, tools); - - logger.debug('LLM Response:', { type: response.type, contentLength: response.content?.length, content: response.content?.slice(0, 200) }); - + + logger.debug('LLM Response:', { + type: response.type, + contentLength: response.content?.length, + content: response.content?.slice(0, 200), + }); + if (response.type === 'text') { const rawToolCall = extractToolCallFromText(response.content || ''); // Only treat as a tool call if the extracted name matches a registered tool. // This prevents the LLM's stray JSON from being misinterpreted as tool // invocations, which was causing a "Working on that... Done!" loop. - const toolCall = rawToolCall && tools.some(t => t.name === rawToolCall.name) ? rawToolCall : null; + const toolCall = + rawToolCall && tools.some((t) => t.name === rawToolCall.name) ? rawToolCall : null; if (toolCall) { jsonToolReplies += 1; if (jsonToolReplies > MAX_JSON_TOOL_REPLIES) { - const fallback = "I ran the tool but couldn't produce a final response. Can you rephrase or ask for a summary?"; + const fallback = + "I ran the tool but couldn't produce a final response. Can you rephrase or ask for a summary?"; if (onStream) { onStream({ type: 'text', content: fallback }); onStream({ type: 'done' }); @@ -820,7 +846,7 @@ export async function agentLoop( return fallback; } - const toolResults: Array<{id: string, result: string}> = []; + const toolResults: Array<{ id: string; result: string }> = []; if (onStream) { onStream({ type: 'tool_start', tool: toolCall.name }); @@ -837,7 +863,7 @@ export async function agentLoop( } } - const tool = tools.find(t => t.name === toolCall.name); + const tool = tools.find((t) => t.name === toolCall.name); if (!tool) { const errorMsg = `Error: Tool ${toolCall.name} not found`; toolResults.push({ id: toolCall.id, result: errorMsg }); @@ -907,7 +933,10 @@ export async function agentLoop( return approvalMsg; } // Self-gated: log and fall through to tool execution - logger.info('Shield: skipping approval for self-gated tool', { tool: toolCall.name, reason: decision.reason }); + logger.info('Shield: skipping approval for self-gated tool', { + tool: toolCall.name, + reason: decision.reason, + }); } // action === 'log' — proceed normally, decision is already logged by engine @@ -933,31 +962,36 @@ export async function agentLoop( } // For read/search/recall operations, send result back to LLM for natural response - const isReadOperation = toolCall.name.includes('read') || - toolCall.name.includes('search') || - toolCall.name.includes('recall') || - toolCall.name.includes('list'); - + const isReadOperation = + toolCall.name.includes('read') || + toolCall.name.includes('search') || + toolCall.name.includes('recall') || + toolCall.name.includes('list'); + // Delegation tools now run in background — feed the quick status message // back so the LLM can tell the user in a natural way. const isDelegation = isDelegationTool(toolCall.name); - if ((isReadOperation || isDelegation) && toolResults[0] && !toolResults[0].result.startsWith('Error')) { + if ( + (isReadOperation || isDelegation) && + toolResults[0] && + !toolResults[0].result.startsWith('Error') + ) { // Add tool result to conversation and let LLM respond naturally const preamble = isDelegation ? `I delegated the task to a sub-agent. Status:\n${toolResults[0].result}\n\nTell the user the sub-agent is now working on it in the background and they can keep chatting.` : `I used ${toolCall.name} and got this result:\n${toolResults[0].result}`; - messages.push({ - role: 'assistant', - content: preamble, + messages.push({ + role: 'assistant', + content: preamble, }); - messages.push({ - role: 'user', + messages.push({ + role: 'user', content: isDelegation ? 'Acknowledge the delegation briefly. Let me know the sub-agent is working on it and I can keep chatting.' - : 'Now respond naturally to my original question using that information. Be conversational and summarize the key points.' + : 'Now respond naturally to my original question using that information. Be conversational and summarize the key points.', }); - + // Continue the loop to get LLM's natural response continue; } @@ -966,14 +1000,15 @@ export async function agentLoop( // can craft a natural, conversational response instead of the // generic "Done!" that was causing a feedback loop in the history. const writeResult = toolResults[0]?.result || 'completed'; - const writeSummary = summarizeToolResults([toolCall], toolResults); + const _writeSummary = summarizeToolResults([toolCall], toolResults); messages.push({ role: 'assistant', content: `I used ${toolCall.name} and the result was: ${writeResult}`, }); messages.push({ role: 'user', - content: 'Now respond naturally to my original message. Briefly confirm the action you took and be conversational.', + content: + 'Now respond naturally to my original message. Briefly confirm the action you took and be conversational.', }); // Continue the loop to get LLM's natural response @@ -999,11 +1034,11 @@ export async function agentLoop( return cleanContent; } - + if (response.type === 'tool_calls' && response.toolCalls) { // Execute tools - const toolResults: Array<{id: string, result: string}> = []; - + const toolResults: Array<{ id: string; result: string }> = []; + for (const toolCall of response.toolCalls) { // Notify about tool execution if (onStream) { @@ -1019,8 +1054,8 @@ export async function agentLoop( sentToolProgress = true; } } - - const tool = tools.find(t => t.name === toolCall.name); + + const tool = tools.find((t) => t.name === toolCall.name); if (!tool) { const errorMsg = `Error: Tool ${toolCall.name} not found`; toolResults.push({ id: toolCall.id, result: errorMsg }); @@ -1080,12 +1115,15 @@ export async function agentLoop( continue; } // Self-gated: log and fall through to tool execution - logger.info('Shield: skipping approval for self-gated tool', { tool: toolCall.name, reason: decision.reason }); + logger.info('Shield: skipping approval for self-gated tool', { + tool: toolCall.name, + reason: decision.reason, + }); } // action === 'log' — proceed normally } - + try { // Inject user_id so delegation tools always receive the correct userId const toolArgs = { ...toolCall.arguments, user_id: userId }; @@ -1114,15 +1152,20 @@ export async function agentLoop( const approvalMsg = `Before I run **${pa.toolCall.name}**, I need your approval.\n\n` + `**Reason:** ${pa.decision.reason}\n\n` + - (remainingCount > 1 ? `_(${remainingCount - 1} more tool(s) also pending approval)_\n\n` : '') + + (remainingCount > 1 + ? `_(${remainingCount - 1} more tool(s) also pending approval)_\n\n` + : '') + `Do you want me to go ahead? (yes / no)`; // Still return results for tools that did execute - const executedResults = toolResults.filter(r => - !r.result.startsWith('Requires approval:') && !r.result.startsWith('Blocked by security') + const executedResults = toolResults.filter( + (r) => + !r.result.startsWith('Requires approval:') && + !r.result.startsWith('Blocked by security'), ); - const combined = executedResults.length > 0 - ? `${executedResults.map(r => r.result).join('\n\n')}\n\n---\n\n${approvalMsg}` - : approvalMsg; + const combined = + executedResults.length > 0 + ? `${executedResults.map((r) => r.result).join('\n\n')}\n\n---\n\n${approvalMsg}` + : approvalMsg; if (onStream) { onStream({ type: 'text', content: combined }); onStream({ type: 'done' }); @@ -1131,37 +1174,41 @@ export async function agentLoop( db.saveMessage(userId, 'assistant', combined); return combined; } - + // Check if any tool was a read or delegation operation - const hasReadOperation = response.toolCalls.some(tc => - tc.name.includes('read') || - tc.name.includes('search') || - tc.name.includes('recall') || - tc.name.includes('list') + const hasReadOperation = response.toolCalls.some( + (tc) => + tc.name.includes('read') || + tc.name.includes('search') || + tc.name.includes('recall') || + tc.name.includes('list'), ); - const hasDelegation = response.toolCalls.some(tc => isDelegationTool(tc.name)); - - if ((hasReadOperation || hasDelegation) && toolResults.some(r => !r.result.startsWith('Error'))) { + const hasDelegation = response.toolCalls.some((tc) => isDelegationTool(tc.name)); + + if ( + (hasReadOperation || hasDelegation) && + toolResults.some((r) => !r.result.startsWith('Error')) + ) { // Add tool results to conversation and let LLM respond naturally - const resultsText = toolResults.map(r => r.result).join('\n\n'); + const resultsText = toolResults.map((r) => r.result).join('\n\n'); const preamble = hasDelegation ? `I delegated the task(s) to sub-agent(s). Status:\n${resultsText}\n\nTell the user the sub-agent(s) are working in the background and they can keep chatting.` : `I retrieved this information:\n${resultsText}`; - messages.push({ - role: 'assistant', + messages.push({ + role: 'assistant', content: preamble, }); - messages.push({ - role: 'user', + messages.push({ + role: 'user', content: hasDelegation ? 'Acknowledge the delegation briefly. Let me know the sub-agent is working on it and I can keep chatting.' - : 'Now respond naturally to my original question using that information. Be conversational and summarize the key points.' + : 'Now respond naturally to my original question using that information. Be conversational and summarize the key points.', }); - + // Continue the loop to get LLM's natural response continue; } - + const responseText = summarizeToolResults(response.toolCalls, toolResults); if (onStream) { @@ -1180,10 +1227,10 @@ export async function agentLoop( return responseText; } } - + if (onStream) { onStream({ type: 'error', error: 'Maximum tool iterations reached' }); } - - return "I got stuck thinking. Can you try again?"; + + return 'I got stuck thinking. Can you try again?'; } diff --git a/packages/core/src/messages.ts b/packages/core/src/messages.ts index da19813..db47e9b 100644 --- a/packages/core/src/messages.ts +++ b/packages/core/src/messages.ts @@ -9,41 +9,41 @@ */ // Re-export so consumers of '@tinyclaw/core/messages' get everything they need -export { DEFAULT_MODEL } from './models.js' +export { DEFAULT_MODEL } from './models.js'; // --------------------------------------------------------------------------- // Security warning // --------------------------------------------------------------------------- -export const SECURITY_WARNING_TITLE = 'Security warning — please read carefully.' +export const SECURITY_WARNING_TITLE = 'Security warning — please read carefully.'; export const SECURITY_WARNING_BODY = 'Tiny Claw is an open-source AI agent that runs on your machine. ' + 'It can read files, execute code, and perform actions when tools are enabled. ' + 'A malicious or poorly crafted prompt could trick the agent into ' + - 'performing unintended or harmful operations.' + 'performing unintended or harmful operations.'; export const SECURITY_LICENSE = 'This software is licensed under the GNU General Public License v3.0 (GPLv3). ' + - 'You are free to use, modify, and distribute it under the terms of that license.' + 'You are free to use, modify, and distribute it under the terms of that license.'; export const SECURITY_WARRANTY = 'This software is provided "AS IS", without warranty of any kind. ' + 'The authors and contributors are not liable for any damages, data loss, ' + - 'or security incidents arising from its use. You assume all risks.' + 'or security incidents arising from its use. You assume all risks.'; -export const SECURITY_SAFETY_TITLE = 'Recommended safety practices:' +export const SECURITY_SAFETY_TITLE = 'Recommended safety practices:'; export const SECURITY_SAFETY_PRACTICES = [ 'Run in a sandboxed or isolated environment when possible.', 'Never expose Tiny Claw to the public internet without access control.', - 'Keep secrets and sensitive files out of the agent\'s reachable paths.', + "Keep secrets and sensitive files out of the agent's reachable paths.", 'Review enabled tools and permissions regularly.', 'Use the strongest available model for any bot with tool access.', 'Keep Tiny Claw up to date for the latest security patches.', -] as const +] as const; -export const SECURITY_CONFIRM = 'I understand the risks and want to proceed' +export const SECURITY_CONFIRM = 'I understand the risks and want to proceed'; // --------------------------------------------------------------------------- // Default model @@ -58,20 +58,20 @@ export function defaultModelNote(modelTag: string): string { `Your default built-in model is ${modelTag}.\n\n` + 'This model is always available as your fallback. If your primary\n' + 'model is down or hits a rate limit, Tiny Claw automatically falls\n' + - 'back to this one so you\'re never left without a brain.\n\n' + + "back to this one so you're never left without a brain.\n\n" + 'You can switch the default model anytime by asking the AI agent\n' + 'during a conversation (e.g. "switch to gpt-oss:120b-cloud").' - ) + ); } // --------------------------------------------------------------------------- // TOTP setup // --------------------------------------------------------------------------- -export const TOTP_SETUP_TITLE = 'Set up TOTP' +export const TOTP_SETUP_TITLE = 'Set up TOTP'; export const TOTP_SETUP_BODY = - 'Add this key in your authenticator app, then enter the code it generates.' + 'Add this key in your authenticator app, then enter the code it generates.'; // --------------------------------------------------------------------------- // Backup codes & recovery @@ -79,10 +79,10 @@ export const TOTP_SETUP_BODY = export const BACKUP_CODES_INTRO = 'Save these backup codes and your recovery token now. ' + - 'You will need both to recover access if you lose your authenticator.' + 'You will need both to recover access if you lose your authenticator.'; export const BACKUP_CODES_HINT = - 'Each backup code can only be used once. Keep them in a secure place separate from your authenticator.' + 'Each backup code can only be used once. Keep them in a secure place separate from your authenticator.'; export const RECOVERY_TOKEN_HINT = - 'Go to /recovery and enter this token to start the recovery process.' + 'Go to /recovery and enter this token to start the recovery process.'; diff --git a/packages/core/src/owner-auth.ts b/packages/core/src/owner-auth.ts index d0db384..2f0a2e4 100644 --- a/packages/core/src/owner-auth.ts +++ b/packages/core/src/owner-auth.ts @@ -9,7 +9,7 @@ * Works in Bun and Node 20+. */ -import { timingSafeEqual } from 'node:crypto' +import { timingSafeEqual } from 'node:crypto'; // --------------------------------------------------------------------------- // Constants @@ -18,15 +18,15 @@ import { timingSafeEqual } from 'node:crypto' /** * Human-friendly alphabet — excludes ambiguous characters (0/O, 1/I/L). */ -const TOKEN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' +const TOKEN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; -const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' -const TOTP_STEP_SECONDS = 30 -const TOTP_DIGITS = 6 +const TOTP_BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +const TOTP_STEP_SECONDS = 30; +const TOTP_DIGITS = 6; -export const BACKUP_CODES_COUNT = 10 -export const BACKUP_CODE_LENGTH = 30 -export const RECOVERY_TOKEN_LENGTH = 200 +export const BACKUP_CODES_COUNT = 10; +export const BACKUP_CODE_LENGTH = 30; +export const RECOVERY_TOKEN_LENGTH = 200; // --------------------------------------------------------------------------- // Token / code generators @@ -34,21 +34,21 @@ export const RECOVERY_TOKEN_LENGTH = 200 /** Generate a cryptographically random recovery token. */ export function generateRecoveryToken(): string { - const bytes = crypto.getRandomValues(new Uint8Array(RECOVERY_TOKEN_LENGTH)) - return Array.from(bytes, b => TOKEN_ALPHABET[b % TOKEN_ALPHABET.length]).join('') + const bytes = crypto.getRandomValues(new Uint8Array(RECOVERY_TOKEN_LENGTH)); + return Array.from(bytes, (b) => TOKEN_ALPHABET[b % TOKEN_ALPHABET.length]).join(''); } /** Generate a single backup code. */ export function generateBackupCode(): string { - const bytes = crypto.getRandomValues(new Uint8Array(BACKUP_CODE_LENGTH)) - return Array.from(bytes, b => TOKEN_ALPHABET[b % TOKEN_ALPHABET.length]).join('') + const bytes = crypto.getRandomValues(new Uint8Array(BACKUP_CODE_LENGTH)); + return Array.from(bytes, (b) => TOKEN_ALPHABET[b % TOKEN_ALPHABET.length]).join(''); } /** Generate a batch of backup codes. */ export function generateBackupCodes(count = BACKUP_CODES_COUNT): string[] { - const codes: string[] = [] - for (let i = 0; i < count; i++) codes.push(generateBackupCode()) - return codes + const codes: string[] = []; + for (let i = 0; i < count; i++) codes.push(generateBackupCode()); + return codes; } // --------------------------------------------------------------------------- @@ -57,51 +57,47 @@ export function generateBackupCodes(count = BACKUP_CODES_COUNT): string[] { /** Generate a random Base32-encoded TOTP secret. */ export function generateTotpSecret(length = 32): string { - const bytes = crypto.getRandomValues(new Uint8Array(length)) - return Array.from(bytes, b => TOTP_BASE32_ALPHABET[b % TOTP_BASE32_ALPHABET.length]).join('') + const bytes = crypto.getRandomValues(new Uint8Array(length)); + return Array.from(bytes, (b) => TOTP_BASE32_ALPHABET[b % TOTP_BASE32_ALPHABET.length]).join(''); } /** Decode a Base32 string to bytes. */ function base32Decode(input: string): Uint8Array { - const cleaned = input.toUpperCase().replace(/=+$/g, '') - let bits = 0 - let value = 0 - const bytes: number[] = [] + const cleaned = input.toUpperCase().replace(/=+$/g, ''); + let bits = 0; + let value = 0; + const bytes: number[] = []; for (const ch of cleaned) { - const idx = TOTP_BASE32_ALPHABET.indexOf(ch) - if (idx < 0) throw new Error('Invalid Base32') - value = (value << 5) | idx - bits += 5 + const idx = TOTP_BASE32_ALPHABET.indexOf(ch); + if (idx < 0) throw new Error('Invalid Base32'); + value = (value << 5) | idx; + bits += 5; if (bits >= 8) { - bytes.push((value >>> (bits - 8)) & 0xff) - bits -= 8 + bytes.push((value >>> (bits - 8)) & 0xff); + bits -= 8; } } - return Uint8Array.from(bytes) + return Uint8Array.from(bytes); } /** Build an `otpauth://` URI for QR-code scanning or manual entry. */ -export function createTotpUri( - secret: string, - accountName = 'owner', - issuer = 'Tiny Claw', -): string { +export function createTotpUri(secret: string, accountName = 'owner', issuer = 'Tiny Claw'): string { return ( `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}` + `?secret=${encodeURIComponent(secret)}` + `&issuer=${encodeURIComponent(issuer)}` + `&algorithm=SHA1&digits=${TOTP_DIGITS}&period=${TOTP_STEP_SECONDS}` - ) + ); } /** Compute a single TOTP code for the given counter value. */ export async function generateTotpCode(secret: string, counter: number): Promise { - const keyData = base32Decode(secret) - const counterBuffer = new ArrayBuffer(8) - const view = new DataView(counterBuffer) - view.setUint32(4, counter >>> 0) + const keyData = base32Decode(secret); + const counterBuffer = new ArrayBuffer(8); + const view = new DataView(counterBuffer); + view.setUint32(4, counter >>> 0); const cryptoKey = await crypto.subtle.importKey( 'raw', @@ -109,19 +105,19 @@ export async function generateTotpCode(secret: string, counter: number): Promise { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'], - ) + ); - const signature = await crypto.subtle.sign('HMAC', cryptoKey, counterBuffer) - const hmac = new Uint8Array(signature) - const offset = hmac[hmac.length - 1] & 0x0f + const signature = await crypto.subtle.sign('HMAC', cryptoKey, counterBuffer); + const hmac = new Uint8Array(signature); + const offset = hmac[hmac.length - 1] & 0x0f; const binary = ((hmac[offset] & 0x7f) << 24) | ((hmac[offset + 1] & 0xff) << 16) | ((hmac[offset + 2] & 0xff) << 8) | - (hmac[offset + 3] & 0xff) + (hmac[offset + 3] & 0xff); - const otp = binary % (10 ** TOTP_DIGITS) - return String(otp).padStart(TOTP_DIGITS, '0') + const otp = binary % 10 ** TOTP_DIGITS; + return String(otp).padStart(TOTP_DIGITS, '0'); } /** @@ -129,15 +125,15 @@ export async function generateTotpCode(secret: string, counter: number): Promise * Uses Node's crypto.timingSafeEqual for constant-time comparison. */ function defaultSafeCompare(a: string, b: string): boolean { - const encoder = new TextEncoder() - const bufA = encoder.encode(a) - const bufB = encoder.encode(b) + const encoder = new TextEncoder(); + const bufA = encoder.encode(a); + const bufB = encoder.encode(b); if (bufA.byteLength !== bufB.byteLength) { // Burn the same CPU time so length differences don't leak timing info - timingSafeEqual(bufA, bufA) - return false + timingSafeEqual(bufA, bufA); + return false; } - return timingSafeEqual(bufA, bufB) + return timingSafeEqual(bufA, bufB); } /** @@ -151,15 +147,15 @@ export async function verifyTotpCode( code: string, safeCompare: (a: string, b: string) => boolean = defaultSafeCompare, ): Promise { - const normalized = String(code || '').replace(/\s+/g, '') - if (!/^\d{6}$/.test(normalized)) return false + const normalized = String(code || '').replace(/\s+/g, ''); + if (!/^\d{6}$/.test(normalized)) return false; - const nowCounter = Math.floor(Date.now() / 1000 / TOTP_STEP_SECONDS) + const nowCounter = Math.floor(Date.now() / 1000 / TOTP_STEP_SECONDS); for (const drift of [-1, 0, 1]) { - const expected = await generateTotpCode(secret, nowCounter + drift) - if (safeCompare(normalized, expected)) return true + const expected = await generateTotpCode(secret, nowCounter + drift); + if (safeCompare(normalized, expected)) return true; } - return false + return false; } // --------------------------------------------------------------------------- @@ -168,13 +164,13 @@ export async function verifyTotpCode( /** SHA-256 hash a string — for storing token/code hashes in config. */ export async function sha256(input: string): Promise { - const data = new TextEncoder().encode(input) - const hash = await crypto.subtle.digest('SHA-256', data) - return Array.from(new Uint8Array(hash), b => b.toString(16).padStart(2, '0')).join('') + const data = new TextEncoder().encode(input); + const hash = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, '0')).join(''); } /** Generate a 48-hex-char session token (192-bit entropy). */ export function generateSessionToken(): string { - const bytes = crypto.getRandomValues(new Uint8Array(24)) - return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('') + const bytes = crypto.getRandomValues(new Uint8Array(24)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); } diff --git a/packages/core/src/update-checker.ts b/packages/core/src/update-checker.ts index 6ecc7ce..d04fd3a 100644 --- a/packages/core/src/update-checker.ts +++ b/packages/core/src/update-checker.ts @@ -12,8 +12,8 @@ * tool) from Docker containers (manual pull required). */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { logger } from '@tinyclaw/logger'; // --------------------------------------------------------------------------- @@ -97,10 +97,7 @@ const SAFE_URL_RE = /^https?:\/\/[^\s]+$/; * Validates against an expected pattern and strips characters that could * be used for prompt injection (newlines, backticks, markdown markers). */ -export function sanitizeForPrompt( - value: string, - kind: 'version' | 'url', -): string { +export function sanitizeForPrompt(value: string, kind: 'version' | 'url'): string { const trimmed = value.trim(); if (kind === 'version') { if (!SEMVER_RE.test(trimmed)) return 'unknown'; @@ -110,7 +107,7 @@ export function sanitizeForPrompt( // kind === 'url' if (!SAFE_URL_RE.test(trimmed)) return '(unavailable)'; // Remove characters that could break prompt formatting - return trimmed.replace(/[`\n\r\[\](){}#*_~>|]/g, ''); + return trimmed.replace(/[`\n\r[\](){}#*_~>|]/g, ''); } /** @@ -124,7 +121,10 @@ export function isNewerVersion(current: string, latest: string): boolean { .replace(/^v/, '') .replace(/[-+].*$/, '') .split('.') - .map((s) => { const n = Number(s); return isNaN(n) ? 0 : n; }) + .map((s) => { + const n = Number(s); + return Number.isNaN(n) ? 0 : n; + }) .slice(0, 3); const [cMaj = 0, cMin = 0, cPat = 0] = parse(current); const [lMaj = 0, lMin = 0, lPat = 0] = parse(latest); @@ -146,7 +146,13 @@ function readCache(dataDir: string): UpdateInfo | null { const raw = readFileSync(getCachePath(dataDir), 'utf-8'); const cached = JSON.parse(raw) as UpdateInfo; const validRuntimes: UpdateRuntime[] = ['npm', 'docker', 'source']; - if (cached && typeof cached.checkedAt === 'number' && typeof cached.latest === 'string' && validRuntimes.includes(cached.runtime as UpdateRuntime)) return cached; + if ( + cached && + typeof cached.checkedAt === 'number' && + typeof cached.latest === 'string' && + validRuntimes.includes(cached.runtime as UpdateRuntime) + ) + return cached; } catch { // Missing or corrupt — will re-check } @@ -246,7 +252,11 @@ export async function checkForUpdate( writeCache(dataDir, info); if (info.updateAvailable) { - logger.info('Update available', { current: currentVersion, latest, runtime }, { emoji: '🆕' }); + logger.info( + 'Update available', + { current: currentVersion, latest, runtime }, + { emoji: '🆕' }, + ); } return info; diff --git a/packages/core/tests/update-checker.test.ts b/packages/core/tests/update-checker.test.ts index 1b4a909..4dbcab1 100644 --- a/packages/core/tests/update-checker.test.ts +++ b/packages/core/tests/update-checker.test.ts @@ -5,15 +5,15 @@ * building, cache I/O, and the main checkForUpdate flow. */ -import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; -import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { - isNewerVersion, - detectRuntime, buildUpdateContext, checkForUpdate, + detectRuntime, + isNewerVersion, sanitizeForPrompt, type UpdateInfo, } from '../src/update-checker.js'; @@ -193,29 +193,23 @@ describe('checkForUpdate', () => { test('returns cached result if cache is fresh', async () => { const cached = makeMockInfo({ checkedAt: Date.now() }); - writeFileSync( - join(tempDir, 'data', 'update-check.json'), - JSON.stringify(cached), - ); + writeFileSync(join(tempDir, 'data', 'update-check.json'), JSON.stringify(cached)); const result = await checkForUpdate('1.0.0', tempDir); expect(result).not.toBeNull(); - expect(result!.latest).toBe('1.1.0'); - expect(result!.updateAvailable).toBe(true); + expect(result?.latest).toBe('1.1.0'); + expect(result?.updateAvailable).toBe(true); }); test('re-evaluates updateAvailable against current version', async () => { // Cache says latest=1.1.0, but we're now running 1.1.0 const cached = makeMockInfo({ checkedAt: Date.now(), latest: '1.1.0' }); - writeFileSync( - join(tempDir, 'data', 'update-check.json'), - JSON.stringify(cached), - ); + writeFileSync(join(tempDir, 'data', 'update-check.json'), JSON.stringify(cached)); const result = await checkForUpdate('1.1.0', tempDir); expect(result).not.toBeNull(); - expect(result!.updateAvailable).toBe(false); - expect(result!.current).toBe('1.1.0'); + expect(result?.updateAvailable).toBe(false); + expect(result?.current).toBe('1.1.0'); }); test('returns null on network failure with no cache', async () => { diff --git a/packages/delegation/src/background.ts b/packages/delegation/src/background.ts index 198e45e..8cf4dba 100644 --- a/packages/delegation/src/background.ts +++ b/packages/delegation/src/background.ts @@ -6,18 +6,11 @@ * next conversation turn via notification injection in agentLoop. */ -import type { Provider, Tool, Message } from '@tinyclaw/types'; import { logger } from '@tinyclaw/logger'; -import type { DelegationStore, DelegationQueue, DelegationIntercom } from './store.js'; -import type { - BackgroundRunner, - BackgroundTaskRecord, - LifecycleManager, - TemplateManager, - OrientationContext, -} from './types.js'; -import type { TimeoutEstimator } from './timeout-estimator.js'; import { runSubAgentV2 } from './runner.js'; +import type { DelegationIntercom, DelegationQueue, DelegationStore } from './store.js'; +import type { TimeoutEstimator } from './timeout-estimator.js'; +import type { BackgroundRunner, LifecycleManager, TemplateManager } from './types.js'; // --------------------------------------------------------------------------- // Constants @@ -59,9 +52,7 @@ export function createBackgroundRunner( } = config; // Enforce concurrency limit - const running = db.getUndeliveredTasks(userId).filter( - (t) => t.status === 'running', - ); + const running = db.getUndeliveredTasks(userId).filter((t) => t.status === 'running'); // Also check tasks that are in the undelivered list but not yet completed // getUndeliveredTasks returns completed+failed, so count running from all tasks const agent = db.getSubAgent(agentId); @@ -130,7 +121,14 @@ export function createBackgroundRunner( if (timeoutEstimator) { const durationMs = Date.now() - startTime; const taskType = timeoutEstimator.classifyTask(task); - timeoutEstimator.record(userId, taskType, agent?.tierPreference ?? 'auto', durationMs, result.iterations, result.success); + timeoutEstimator.record( + userId, + taskType, + agent?.tierPreference ?? 'auto', + durationMs, + result.iterations, + result.success, + ); } // Update task record @@ -186,7 +184,10 @@ export function createBackgroundRunner( ); if (!hasRunningTasks) { lifecycle.dismiss(agentId); - logger.info(`Sub-agent auto-dismissed (task ${result.success ? 'complete' : 'failed'})`, { agentId }); + logger.info( + `Sub-agent auto-dismissed (task ${result.success ? 'complete' : 'failed'})`, + { agentId }, + ); } } catch (err) { const errorMsg = err instanceof Error ? err.message : 'Unknown error'; diff --git a/packages/delegation/src/blackboard.ts b/packages/delegation/src/blackboard.ts index 24a2348..028e842 100644 --- a/packages/delegation/src/blackboard.ts +++ b/packages/delegation/src/blackboard.ts @@ -17,7 +17,7 @@ */ import type { BlackboardEntry } from '@tinyclaw/types'; -import type { DelegationStore, DelegationIntercom } from './store.js'; +import type { DelegationIntercom, DelegationStore } from './store.js'; // --------------------------------------------------------------------------- // Types diff --git a/packages/delegation/src/compat.ts b/packages/delegation/src/compat.ts index 0f51b8d..92ab726 100644 --- a/packages/delegation/src/compat.ts +++ b/packages/delegation/src/compat.ts @@ -6,8 +6,8 @@ */ import { logger } from '@tinyclaw/logger'; -import type { Tool } from '@tinyclaw/types'; -import type { ProviderOrchestrator, QueryTier } from '@tinyclaw/router'; +import type { QueryTier } from '@tinyclaw/router'; +import type { Provider, Tool } from '@tinyclaw/types'; import { runSubAgent } from './runner.js'; import type { DelegationToolConfig } from './types.js'; @@ -44,8 +44,7 @@ export function createDelegationTool(config: DelegationToolConfig): Tool { properties: { task: { type: 'string', - description: - 'Clear, detailed description of what the sub-agent should accomplish', + description: 'Clear, detailed description of what the sub-agent should accomplish', }, role: { type: 'string', @@ -85,12 +84,10 @@ export function createDelegationTool(config: DelegationToolConfig): Tool { } // 1. Resolve provider - let provider; + let provider: Provider; try { if (tierOverride) { - provider = orchestrator - .getRegistry() - .getForTier(tierOverride as QueryTier); + provider = orchestrator.getRegistry().getForTier(tierOverride as QueryTier); } else { const routeResult = await orchestrator.routeWithHealth(task); provider = routeResult.provider; @@ -107,15 +104,10 @@ export function createDelegationTool(config: DelegationToolConfig): Tool { }); // 2. Assemble tool set - const allowedToolNames = new Set([ - ...safeToolNames, - ...additionalToolNames, - ]); + const allowedToolNames = new Set([...safeToolNames, ...additionalToolNames]); allowedToolNames.delete('delegate_task'); - const subAgentTools = allTools.filter((t) => - allowedToolNames.has(t.name), - ); + const subAgentTools = allTools.filter((t) => allowedToolNames.has(t.name)); // 3. Run sub-agent const result = await runSubAgent({ diff --git a/packages/delegation/src/index.ts b/packages/delegation/src/index.ts index 4668c3d..fd8ce85 100644 --- a/packages/delegation/src/index.ts +++ b/packages/delegation/src/index.ts @@ -4,63 +4,48 @@ * Re-exports all delegation functionality. Backward compatible with v1 imports. */ -// Store interfaces (DelegationStore, DelegationQueue, DelegationIntercom) -export type { DelegationStore, DelegationQueue, DelegationIntercom } from './store.js'; - -// V1 compatible exports (runner + types) -export { runSubAgent } from './runner.js'; -export type { - SubAgentConfig, - SubAgentResult, - DelegationToolConfig, -} from './types.js'; - -// V2 exports — runner -export { runSubAgentV2 } from './runner.js'; - -// V2 exports — orientation -export { buildOrientationContext, formatOrientation } from './orientation.js'; - +// V2 exports — background +export { createBackgroundRunner } from './background.js'; +export type { Blackboard, BlackboardProblem } from './blackboard.js'; +// V3 exports — blackboard +export { createBlackboard } from './blackboard.js'; +// V1 createDelegationTool (backward compatible) +export { createDelegationTool } from './compat.js'; // V2 exports — handbook export { DELEGATION_HANDBOOK, DELEGATION_TOOL_NAMES } from './handbook.js'; - // V2 exports — lifecycle export { createLifecycleManager } from './lifecycle.js'; - +// V2 exports — orientation +export { buildOrientationContext, formatOrientation } from './orientation.js'; +// V1 compatible exports (runner + types) +// V2 exports — runner +export { runSubAgent, runSubAgentV2 } from './runner.js'; +// Store interfaces (DelegationStore, DelegationQueue, DelegationIntercom) +export type { DelegationIntercom, DelegationQueue, DelegationStore } from './store.js'; // V2 exports — templates export { createTemplateManager } from './templates.js'; - -// V2 exports — background -export { createBackgroundRunner } from './background.js'; - +export type { ExtensionDecision, TimeoutEstimate, TimeoutEstimator } from './timeout-estimator.js'; +// V3 exports — timeout estimator +export { createTimeoutEstimator } from './timeout-estimator.js'; +export type { DelegationToolsConfig } from './tools.js'; // V2 exports — tools (6-tool factory) export { createDelegationTools } from './tools.js'; -export type { DelegationToolsConfig } from './tools.js'; - // V2 exports — types export type { - SubAgentStatus, - SubAgentRecord, - RoleTemplate, - BackgroundTaskStatus, + BackgroundRunner, BackgroundTaskRecord, - OrientationContext, + BackgroundTaskStatus, + DelegationContext, + DelegationToolConfig, DelegationV2Config, + LifecycleManager, + OrientationContext, + RoleTemplate, + SubAgentConfig, + SubAgentRecord, + SubAgentResult, SubAgentRunConfig, SubAgentRunResult, - DelegationContext, - LifecycleManager, + SubAgentStatus, TemplateManager, - BackgroundRunner, } from './types.js'; - -// V1 createDelegationTool (backward compatible) -export { createDelegationTool } from './compat.js'; - -// V3 exports — blackboard -export { createBlackboard } from './blackboard.js'; -export type { Blackboard, BlackboardProblem } from './blackboard.js'; - -// V3 exports — timeout estimator -export { createTimeoutEstimator } from './timeout-estimator.js'; -export type { TimeoutEstimator, TimeoutEstimate, ExtensionDecision } from './timeout-estimator.js'; diff --git a/packages/delegation/src/lifecycle.ts b/packages/delegation/src/lifecycle.ts index b87f5e3..36666ad 100644 --- a/packages/delegation/src/lifecycle.ts +++ b/packages/delegation/src/lifecycle.ts @@ -6,14 +6,10 @@ * and message persistence. */ -import type { Message, SubAgentRecord } from '@tinyclaw/types'; -import type { QueryTier } from '@tinyclaw/router'; -import type { DelegationStore } from './store.js'; -import type { - LifecycleManager, - OrientationContext, -} from './types.js'; +import type { SubAgentRecord } from '@tinyclaw/types'; import { formatOrientation } from './orientation.js'; +import type { DelegationStore } from './store.js'; +import type { LifecycleManager } from './types.js'; // --------------------------------------------------------------------------- // Constants @@ -39,13 +35,70 @@ const REUSE_THRESHOLD = 0.6; // --------------------------------------------------------------------------- const STOP_WORDS = new Set([ - 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being', - 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', - 'should', 'may', 'might', 'shall', 'can', 'to', 'of', 'in', 'for', - 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'about', 'like', - 'through', 'after', 'over', 'between', 'out', 'up', 'that', 'this', - 'it', 'and', 'or', 'but', 'not', 'no', 'so', 'if', 'then', 'than', - 'too', 'very', 'just', 'also', 'more', 'some', 'any', 'each', 'all', + 'a', + 'an', + 'the', + 'is', + 'are', + 'was', + 'were', + 'be', + 'been', + 'being', + 'have', + 'has', + 'had', + 'do', + 'does', + 'did', + 'will', + 'would', + 'could', + 'should', + 'may', + 'might', + 'shall', + 'can', + 'to', + 'of', + 'in', + 'for', + 'on', + 'with', + 'at', + 'by', + 'from', + 'as', + 'into', + 'about', + 'like', + 'through', + 'after', + 'over', + 'between', + 'out', + 'up', + 'that', + 'this', + 'it', + 'and', + 'or', + 'but', + 'not', + 'no', + 'so', + 'if', + 'then', + 'than', + 'too', + 'very', + 'just', + 'also', + 'more', + 'some', + 'any', + 'each', + 'all', ]); function tokenize(text: string): Set { @@ -74,14 +127,7 @@ function keywordOverlap(a: Set, b: Set): number { export function createLifecycleManager(db: DelegationStore): LifecycleManager { return { create(config) { - const { - userId, - role, - toolsGranted, - tierPreference, - templateId, - orientation, - } = config; + const { userId, role, toolsGranted, tierPreference, templateId, orientation } = config; // Enforce max active limit const active = db.getActiveSubAgents(userId); @@ -130,7 +176,9 @@ export function createLifecycleManager(db: DelegationStore): LifecycleManager { findReusable(userId, role) { // Check active, suspended, and soft_deleted agents for reuse const all = db.getAllSubAgents(userId, true); - const candidates = all.filter(a => a.status === 'active' || a.status === 'suspended' || a.status === 'soft_deleted'); + const candidates = all.filter( + (a) => a.status === 'active' || a.status === 'suspended' || a.status === 'soft_deleted', + ); if (candidates.length === 0) return null; const requestTokens = tokenize(role); diff --git a/packages/delegation/src/orientation.ts b/packages/delegation/src/orientation.ts index b1c5081..9d50e9e 100644 --- a/packages/delegation/src/orientation.ts +++ b/packages/delegation/src/orientation.ts @@ -40,9 +40,7 @@ export function buildOrientationContext(config: { const { heartwareContext, learning, db, userId, getCompactedContext } = config; // 1. Identity — truncate heartware context to keep prompt lean - const identity = heartwareContext - ? truncate(heartwareContext, MAX_IDENTITY_CHARS) - : ''; + const identity = heartwareContext ? truncate(heartwareContext, MAX_IDENTITY_CHARS) : ''; // 2. Preferences — from learning engine const learnedContext = learning.getContext(); @@ -61,9 +59,7 @@ export function buildOrientationContext(config: { const memoryEntries = Object.entries(memoryMap); let memories = ''; if (memoryEntries.length > 0) { - memories = memoryEntries - .map(([key, value]) => `- ${key}: ${value}`) - .join('\n'); + memories = memoryEntries.map(([key, value]) => `- ${key}: ${value}`).join('\n'); memories = truncate(memories, MAX_MEMORIES_CHARS); } @@ -119,5 +115,5 @@ export function formatOrientation(ctx: OrientationContext): string { function truncate(text: string, maxChars: number): string { if (text.length <= maxChars) return text; - return text.slice(0, maxChars) + '...'; + return `${text.slice(0, maxChars)}...`; } diff --git a/packages/delegation/src/runner.ts b/packages/delegation/src/runner.ts index a305d95..13df731 100644 --- a/packages/delegation/src/runner.ts +++ b/packages/delegation/src/runner.ts @@ -9,16 +9,24 @@ */ import { logger } from '@tinyclaw/logger'; -import type { Provider, Tool, Message, ToolCall, ShieldEngine, ShieldEvent } from '@tinyclaw/types'; import type { + LLMResponse, + Message, + Provider, + ShieldEngine, + ShieldEvent, + Tool, + ToolCall, +} from '@tinyclaw/types'; +import { formatOrientation } from './orientation.js'; +import type { TimeoutEstimator } from './timeout-estimator.js'; +import type { + OrientationContext, SubAgentConfig, SubAgentResult, SubAgentRunConfig, SubAgentRunResult, - OrientationContext, } from './types.js'; -import type { TimeoutEstimator } from './timeout-estimator.js'; -import { formatOrientation } from './orientation.js'; // --------------------------------------------------------------------------- // Constants @@ -34,9 +42,7 @@ const TOOL_ACTION_KEYS = ['action', 'tool', 'name']; // extraction into core/src/tool-utils.ts) // --------------------------------------------------------------------------- -function normalizeToolArguments( - args: Record, -): Record { +function normalizeToolArguments(args: Record): Record { const normalized = { ...args }; if (!('filename' in normalized) && 'file_path' in normalized) { @@ -128,14 +134,11 @@ async function executeToolCall( // Build system prompt // --------------------------------------------------------------------------- -function buildSubAgentPrompt( - role: string, - orientation?: OrientationContext, -): string { +function buildSubAgentPrompt(role: string, orientation?: OrientationContext): string { let prompt = ''; if (orientation) { - prompt += formatOrientation(orientation) + '\n\n'; + prompt += `${formatOrientation(orientation)}\n\n`; } prompt += @@ -171,7 +174,7 @@ interface AdaptiveLoopConfig { shield?: ShieldEngine; } -async function runAgentLoop( +async function _runAgentLoop( provider: Provider, tools: Tool[], messages: Message[], @@ -232,8 +235,14 @@ async function runAdaptiveAgentLoop( const onAbort = () => reject(new DOMException('Aborted', 'AbortError')); signal.addEventListener('abort', onAbort, { once: true }); promise.then( - (v) => { signal.removeEventListener('abort', onAbort); resolve(v); }, - (e) => { signal.removeEventListener('abort', onAbort); reject(e); }, + (v) => { + signal.removeEventListener('abort', onAbort); + resolve(v); + }, + (e) => { + signal.removeEventListener('abort', onAbort); + reject(e); + }, ); }); } @@ -247,7 +256,7 @@ async function runAdaptiveAgentLoop( iterations = i + 1; - let response; + let response: LLMResponse; try { response = await raceAbort(provider.chat(messages, tools)); } catch (err: any) { @@ -277,7 +286,7 @@ async function runAdaptiveAgentLoop( } else { // Agent is done — signal the timer to stop immediately clearTimer(); - ac.abort(); // harmless if already aborted + ac.abort(); // harmless if already aborted return { success: true, response: response.content || '', @@ -375,9 +384,7 @@ async function runAdaptiveAgentLoop( * * Returns a result object — never throws. */ -export async function runSubAgent( - config: SubAgentConfig, -): Promise { +export async function runSubAgent(config: SubAgentConfig): Promise { const { task, role, provider, tools } = config; const timeout = config.timeout ?? SUB_AGENT_TIMEOUT_MS; @@ -429,27 +436,15 @@ export async function runSubAgent( * * Returns the full message array for persistence by the caller. */ -export async function runSubAgentV2( - config: SubAgentRunConfig, -): Promise { - const { - task, - role, - provider, - tools, - orientation, - existingMessages, - timeoutEstimator, - } = config; +export async function runSubAgentV2(config: SubAgentRunConfig): Promise { + const { task, role, provider, tools, orientation, existingMessages, timeoutEstimator } = config; const timeout = config.timeout ?? SUB_AGENT_TIMEOUT_MS; const maxIter = config.maxIterations ?? SUB_AGENT_MAX_ITERATIONS; const systemPrompt = buildSubAgentPrompt(role, orientation); - const messages: Message[] = [ - { role: 'system', content: systemPrompt }, - ]; + const messages: Message[] = [{ role: 'system', content: systemPrompt }]; // Include existing messages for continuity (reused sub-agents) if (existingMessages?.length) { diff --git a/packages/delegation/src/store.ts b/packages/delegation/src/store.ts index f67db60..d3b889d 100644 --- a/packages/delegation/src/store.ts +++ b/packages/delegation/src/store.ts @@ -7,12 +7,12 @@ */ import type { + BackgroundTask, + BlackboardEntry, Message, - SubAgentRecord, RoleTemplate, - BackgroundTask, + SubAgentRecord, TaskMetricRecord, - BlackboardEntry, } from '@tinyclaw/types'; // --------------------------------------------------------------------------- @@ -38,7 +38,12 @@ export interface DelegationStore { // Background tasks saveBackgroundTask(record: BackgroundTask): void; - updateBackgroundTask(id: string, status: string, result: string | null, completedAt: number | null): void; + updateBackgroundTask( + id: string, + status: string, + result: string | null, + completedAt: number | null, + ): void; getUndeliveredTasks(userId: string): BackgroundTask[]; getUserBackgroundTasks(userId: string): BackgroundTask[]; getBackgroundTask(id: string): BackgroundTask | null; diff --git a/packages/delegation/src/templates.ts b/packages/delegation/src/templates.ts index 8704b29..bbf4b89 100644 --- a/packages/delegation/src/templates.ts +++ b/packages/delegation/src/templates.ts @@ -7,7 +7,6 @@ */ import type { RoleTemplate } from '@tinyclaw/types'; -import type { QueryTier } from '@tinyclaw/router'; import type { DelegationStore } from './store.js'; import type { TemplateManager } from './types.js'; @@ -26,13 +25,70 @@ const MATCH_THRESHOLD = 0.3; // --------------------------------------------------------------------------- const STOP_WORDS = new Set([ - 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being', - 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', - 'should', 'may', 'might', 'shall', 'can', 'to', 'of', 'in', 'for', - 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'about', 'like', - 'through', 'after', 'over', 'between', 'out', 'up', 'that', 'this', - 'it', 'and', 'or', 'but', 'not', 'no', 'so', 'if', 'then', 'than', - 'too', 'very', 'just', 'also', 'more', 'some', 'any', 'each', 'all', + 'a', + 'an', + 'the', + 'is', + 'are', + 'was', + 'were', + 'be', + 'been', + 'being', + 'have', + 'has', + 'had', + 'do', + 'does', + 'did', + 'will', + 'would', + 'could', + 'should', + 'may', + 'might', + 'shall', + 'can', + 'to', + 'of', + 'in', + 'for', + 'on', + 'with', + 'at', + 'by', + 'from', + 'as', + 'into', + 'about', + 'like', + 'through', + 'after', + 'over', + 'between', + 'out', + 'up', + 'that', + 'this', + 'it', + 'and', + 'or', + 'but', + 'not', + 'no', + 'so', + 'if', + 'then', + 'than', + 'too', + 'very', + 'just', + 'also', + 'more', + 'some', + 'any', + 'each', + 'all', ]); function tokenize(text: string): Set { @@ -61,14 +117,7 @@ function keywordScore(queryTokens: Set, templateTokens: Set): nu export function createTemplateManager(db: DelegationStore): TemplateManager { return { create(config) { - const { - userId, - name, - roleDescription, - defaultTools = [], - defaultTier, - tags = [], - } = config; + const { userId, name, roleDescription, defaultTools = [], defaultTier, tags = [] } = config; // Enforce max limit const existing = db.getRoleTemplates(userId); @@ -111,11 +160,7 @@ export function createTemplateManager(db: DelegationStore): TemplateManager { for (const template of templates) { // Build combined token set from tags + role description + name - const templateText = [ - template.name, - template.roleDescription, - ...template.tags, - ].join(' '); + const templateText = [template.name, template.roleDescription, ...template.tags].join(' '); const templateTokens = tokenize(templateText); const score = keywordScore(queryTokens, templateTokens); @@ -135,7 +180,8 @@ export function createTemplateManager(db: DelegationStore): TemplateManager { const dbUpdates: Record = { updatedAt: Date.now() }; if (updates.name !== undefined) dbUpdates.name = updates.name; - if (updates.roleDescription !== undefined) dbUpdates.roleDescription = updates.roleDescription; + if (updates.roleDescription !== undefined) + dbUpdates.roleDescription = updates.roleDescription; if (updates.defaultTools !== undefined) dbUpdates.defaultTools = updates.defaultTools; if (updates.defaultTier !== undefined) dbUpdates.defaultTier = updates.defaultTier; if (updates.tags !== undefined) dbUpdates.tags = updates.tags; diff --git a/packages/delegation/src/timeout-estimator.ts b/packages/delegation/src/timeout-estimator.ts index fd30eb6..2b3000e 100644 --- a/packages/delegation/src/timeout-estimator.ts +++ b/packages/delegation/src/timeout-estimator.ts @@ -81,8 +81,8 @@ export interface TimeoutEstimator { // Constants // --------------------------------------------------------------------------- -const MIN_TIMEOUT_MS = 15_000; // 15 seconds -const MAX_TIMEOUT_MS = 300_000; // 5 minutes +const MIN_TIMEOUT_MS = 15_000; // 15 seconds +const MAX_TIMEOUT_MS = 300_000; // 5 minutes const MIN_DATA_POINTS = 5; const MAX_EXTENSIONS = 2; const EXTENSION_TIME_MS = 30_000; // 30s per extension @@ -115,11 +115,62 @@ const TASK_TYPE_TO_TIER: Record = { * indicate that task type. */ const TASK_TYPE_KEYWORDS: Record = { - research: ['research', 'investigate', 'study', 'explore', 'survey', 'compare', 'analyze', 'review', 'find'], - code: ['code', 'implement', 'build', 'develop', 'program', 'create', 'fix', 'debug', 'refactor', 'write code'], - analysis: ['analysis', 'evaluate', 'assess', 'examine', 'data', 'metric', 'statistic', 'benchmark', 'report'], - writing: ['write', 'draft', 'compose', 'document', 'blog', 'article', 'email', 'summary', 'describe'], - simple_lookup: ['what is', 'define', 'explain', 'list', 'get', 'fetch', 'look up', 'check', 'status'], + research: [ + 'research', + 'investigate', + 'study', + 'explore', + 'survey', + 'compare', + 'analyze', + 'review', + 'find', + ], + code: [ + 'code', + 'implement', + 'build', + 'develop', + 'program', + 'create', + 'fix', + 'debug', + 'refactor', + 'write code', + ], + analysis: [ + 'analysis', + 'evaluate', + 'assess', + 'examine', + 'data', + 'metric', + 'statistic', + 'benchmark', + 'report', + ], + writing: [ + 'write', + 'draft', + 'compose', + 'document', + 'blog', + 'article', + 'email', + 'summary', + 'describe', + ], + simple_lookup: [ + 'what is', + 'define', + 'explain', + 'list', + 'get', + 'fetch', + 'look up', + 'check', + 'status', + ], }; // --------------------------------------------------------------------------- @@ -241,10 +292,7 @@ export function createTimeoutEstimator(db: DelegationStore): TimeoutEstimator { // Case 1: Used most iterations but still have time // Agent is making progress but running out of iterations - if ( - currentIteration >= maxIterations * 0.7 && - elapsedMs < timeoutMs * 0.8 - ) { + if (currentIteration >= maxIterations * 0.7 && elapsedMs < timeoutMs * 0.8) { return { extend: true, extraMs: 0, @@ -254,10 +302,7 @@ export function createTimeoutEstimator(db: DelegationStore): TimeoutEstimator { // Case 2: Running out of time but hasn't used many iterations // Agent is doing heavy computation per iteration - if ( - elapsedMs >= timeoutMs * 0.9 && - currentIteration < maxIterations * 0.5 - ) { + if (elapsedMs >= timeoutMs * 0.9 && currentIteration < maxIterations * 0.5) { return { extend: true, extraMs: EXTENSION_TIME_MS, diff --git a/packages/delegation/src/tools.ts b/packages/delegation/src/tools.ts index e7d14a3..8aeec69 100644 --- a/packages/delegation/src/tools.ts +++ b/packages/delegation/src/tools.ts @@ -12,23 +12,21 @@ * 8. confirm_task — Acknowledge a completed background task */ -import type { Tool, Provider, QueryTier } from '@tinyclaw/types'; import { logger } from '@tinyclaw/logger'; -import type { DelegationStore, DelegationQueue, DelegationIntercom } from './store.js'; +import type { Provider, QueryTier, Tool } from '@tinyclaw/types'; +import { createBackgroundRunner } from './background.js'; +import { DELEGATION_TOOL_NAMES } from './handbook.js'; +import { createLifecycleManager } from './lifecycle.js'; +import { buildOrientationContext } from './orientation.js'; +import type { DelegationIntercom, DelegationQueue } from './store.js'; +import { createTemplateManager } from './templates.js'; import type { + BackgroundRunner, DelegationV2Config, LifecycleManager, - TemplateManager, - BackgroundRunner, OrientationContext, + TemplateManager, } from './types.js'; -import type { TimeoutEstimator } from './timeout-estimator.js'; -import { buildOrientationContext } from './orientation.js'; -import { runSubAgentV2 } from './runner.js'; -import { createLifecycleManager } from './lifecycle.js'; -import { createTemplateManager } from './templates.js'; -import { createBackgroundRunner } from './background.js'; -import { DELEGATION_TOOL_NAMES } from './handbook.js'; // --------------------------------------------------------------------------- // Constants @@ -47,7 +45,7 @@ const DEFAULT_SAFE_TOOLS = new Set([ const MAX_BATCH_SIZE = 10; /** Fallback background sub-agent timeout (used when no estimator). */ -const BACKGROUND_TIMEOUT_MS_FALLBACK = 60_000; +const _BACKGROUND_TIMEOUT_MS_FALLBACK = 60_000; // --------------------------------------------------------------------------- // Helper: filter tools for sub-agents @@ -95,13 +93,18 @@ export function createDelegationTools(config: DelegationToolsConfig): { timeoutEstimator, } = config; - const safeToolSet = defaultSubAgentTools - ? new Set(defaultSubAgentTools) - : DEFAULT_SAFE_TOOLS; + const safeToolSet = defaultSubAgentTools ? new Set(defaultSubAgentTools) : DEFAULT_SAFE_TOOLS; const lifecycle = createLifecycleManager(db); const templates = createTemplateManager(db); - const background = createBackgroundRunner(db, lifecycle, queue, timeoutEstimator, templates, config.intercom); + const background = createBackgroundRunner( + db, + lifecycle, + queue, + timeoutEstimator, + templates, + config.intercom, + ); // Helper: build orientation for the current user function getOrientation(userId: string): OrientationContext { @@ -137,8 +140,14 @@ export function createDelegationTools(config: DelegationToolsConfig): { type: 'object', properties: { task: { type: 'string', description: 'The task to delegate' }, - role: { type: 'string', description: 'Role description (e.g. "Technical Research Analyst")' }, - tier: { type: 'string', description: 'Provider tier: simple, moderate, complex, reasoning, or auto' }, + role: { + type: 'string', + description: 'Role description (e.g. "Technical Research Analyst")', + }, + tier: { + type: 'string', + description: 'Provider tier: simple, moderate, complex, reasoning, or auto', + }, tools: { type: 'array', items: { type: 'string' }, @@ -249,14 +258,23 @@ export function createDelegationTools(config: DelegationToolsConfig): { type: 'object', properties: { task: { type: 'string', description: 'The task to delegate' }, - role: { type: 'string', description: 'Role description (e.g. "Technical Research Analyst")' }, - tier: { type: 'string', description: 'Provider tier: simple, moderate, complex, reasoning, or auto' }, + role: { + type: 'string', + description: 'Role description (e.g. "Technical Research Analyst")', + }, + tier: { + type: 'string', + description: 'Provider tier: simple, moderate, complex, reasoning, or auto', + }, tools: { type: 'array', items: { type: 'string' }, description: 'Additional tool names to grant beyond the default safe set', }, - template_id: { type: 'string', description: 'Optional: use a specific role template' }, + template_id: { + type: 'string', + description: 'Optional: use a specific role template', + }, }, required: ['task', 'role'], }, @@ -391,7 +409,10 @@ export function createDelegationTools(config: DelegationToolsConfig): { properties: { task: { type: 'string', description: 'The task to delegate' }, role: { type: 'string', description: 'Role description' }, - tier: { type: 'string', description: 'Provider tier: simple, moderate, complex, reasoning, or auto' }, + tier: { + type: 'string', + description: 'Provider tier: simple, moderate, complex, reasoning, or auto', + }, tools: { type: 'array', items: { type: 'string' }, @@ -491,7 +512,8 @@ export function createDelegationTools(config: DelegationToolsConfig): { agent = lifecycle.revive(agentId) ?? agent; } - if (agent.status !== 'active') return `Error: Sub-agent ${agentId} is ${agent.status}. Revive it first.`; + if (agent.status !== 'active') + return `Error: Sub-agent ${agentId} is ${agent.status}. Revive it first.`; try { const orientation = getOrientation(userId); @@ -529,7 +551,10 @@ export function createDelegationTools(config: DelegationToolsConfig): { parameters: { type: 'object', properties: { - include_deleted: { type: 'boolean', description: 'Include dismissed agents (default: false)' }, + include_deleted: { + type: 'boolean', + description: 'Include dismissed agents (default: false)', + }, user_id: { type: 'string', description: 'User ID (injected by system)' }, }, }, @@ -549,9 +574,7 @@ export function createDelegationTools(config: DelegationToolsConfig): { const lastActive = new Date(a.lastActiveAt).toISOString().slice(0, 16); // Map internal status to user-friendly labels const statusLabel = - a.status === 'suspended' ? 'idle' - : a.status === 'soft_deleted' ? 'archived' - : a.status; // 'active' stays as-is + a.status === 'suspended' ? 'idle' : a.status === 'soft_deleted' ? 'archived' : a.status; // 'active' stays as-is return `- [${statusLabel}] ${a.role} (${a.id})\n Performance: ${perf} | ${tasks} | Last active: ${lastActive}`; }); @@ -572,7 +595,8 @@ export function createDelegationTools(config: DelegationToolsConfig): { agent_id: { type: 'string', description: 'The sub-agent ID' }, action: { type: 'string', - description: 'Action: dismiss (soft-delete, 14-day retention), revive, or kill (permanent)', + description: + 'Action: dismiss (soft-delete, 14-day retention), revive, or kill (permanent)', }, }, required: ['agent_id', 'action'], @@ -596,7 +620,8 @@ export function createDelegationTools(config: DelegationToolsConfig): { } case 'revive': { const revived = lifecycle.revive(agentId); - if (!revived) return `Error: Cannot revive ${agentId}. It may not be dismissed or has expired.`; + if (!revived) + return `Error: Cannot revive ${agentId}. It may not be dismissed or has expired.`; return `Sub-agent "${revived.role}" (${agentId}) revived and active again.`; } case 'kill': { @@ -760,7 +785,7 @@ export function createDelegationTools(config: DelegationToolsConfig): { // --------------------------------------------------------------------------- /** Extract keyword tags from role + task text. */ -function extractTags(role: string, task: string): string[] { +function _extractTags(role: string, task: string): string[] { const text = `${role} ${task}`.toLowerCase(); const words = text .replace(/[^a-z0-9\s]/g, ' ') diff --git a/packages/delegation/src/types.ts b/packages/delegation/src/types.ts index 7aa5742..8f3cb1a 100644 --- a/packages/delegation/src/types.ts +++ b/packages/delegation/src/types.ts @@ -5,10 +5,9 @@ * sub-agent records, role templates, background tasks, and orientation. */ -import type { QueryTier } from '@tinyclaw/router'; -import type { Provider, Tool, Message, LearningEngine } from '@tinyclaw/types'; -import type { ProviderOrchestrator } from '@tinyclaw/router'; -import type { DelegationStore, DelegationQueue } from './store.js'; +import type { ProviderOrchestrator, QueryTier } from '@tinyclaw/router'; +import type { LearningEngine, Message, Provider, Tool } from '@tinyclaw/types'; +import type { DelegationStore } from './store.js'; import type { TimeoutEstimator } from './timeout-estimator.js'; // --------------------------------------------------------------------------- diff --git a/packages/delegation/tests/blackboard.test.ts b/packages/delegation/tests/blackboard.test.ts index fca847c..53f1898 100644 --- a/packages/delegation/tests/blackboard.test.ts +++ b/packages/delegation/tests/blackboard.test.ts @@ -1,25 +1,36 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { existsSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { createDatabase } from '@tinyclaw/core'; import { createIntercom, type Intercom, type IntercomMessage } from '@tinyclaw/intercom'; -import { createBlackboard, type Blackboard } from '../src/index.js'; import type { Database } from '@tinyclaw/types'; -import { unlinkSync, existsSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; +import { type Blackboard, createBlackboard } from '../src/index.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function createTestDb(): { db: Database; path: string } { - const path = join(tmpdir(), `tinyclaw-test-blackboard-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + const path = join( + tmpdir(), + `tinyclaw-test-blackboard-${Date.now()}-${Math.random().toString(36).slice(2)}.db`, + ); const db = createDatabase(path); return { db, path }; } function cleanupDb(db: Database, path: string): void { - try { db.close(); } catch { /* ignore */ } - try { if (existsSync(path)) unlinkSync(path); } catch { /* ignore */ } + try { + db.close(); + } catch { + /* ignore */ + } + try { + if (existsSync(path)) unlinkSync(path); + } catch { + /* ignore */ + } } // --------------------------------------------------------------------------- @@ -58,13 +69,16 @@ describe('Blackboard', () => { }); it('stored problem can be retrieved', () => { - const problemId = blackboard.postProblem('user1', 'Which database is best for this use case?'); + const problemId = blackboard.postProblem( + 'user1', + 'Which database is best for this use case?', + ); const problem = blackboard.getProblem(problemId); expect(problem).not.toBeNull(); - expect(problem!.problemText).toBe('Which database is best for this use case?'); - expect(problem!.userId).toBe('user1'); - expect(problem!.status).toBe('open'); + expect(problem?.problemText).toBe('Which database is best for this use case?'); + expect(problem?.userId).toBe('user1'); + expect(problem?.status).toBe('open'); }); it('shows up in active problems', () => { @@ -246,7 +260,7 @@ describe('Blackboard', () => { const problem = blackboard.getProblem(problemId); expect(problem).not.toBeNull(); - expect(problem!.problemText).toBe('My problem'); + expect(problem?.problemText).toBe('My problem'); }); }); @@ -324,16 +338,25 @@ describe('Blackboard', () => { // 2. Multiple sub-agents propose solutions blackboard.addProposal( - problemId, 'agent-devops', 'DevOps Engineer', - 'Use blue-green deployment with automatic rollback', 0.85, + problemId, + 'agent-devops', + 'DevOps Engineer', + 'Use blue-green deployment with automatic rollback', + 0.85, ); blackboard.addProposal( - problemId, 'agent-sre', 'SRE', - 'Use canary deployment with gradual rollout', 0.90, + problemId, + 'agent-sre', + 'SRE', + 'Use canary deployment with gradual rollout', + 0.9, ); blackboard.addProposal( - problemId, 'agent-dev', 'Developer', - 'Use feature flags with percentage rollout', 0.70, + problemId, + 'agent-dev', + 'Developer', + 'Use feature flags with percentage rollout', + 0.7, ); // 3. Get proposals sorted by confidence diff --git a/packages/delegation/tests/db.test.ts b/packages/delegation/tests/db.test.ts index dbb7c5d..87e1925 100644 --- a/packages/delegation/tests/db.test.ts +++ b/packages/delegation/tests/db.test.ts @@ -37,14 +37,14 @@ describe('sub_agents table', () => { const record = db.getSubAgent('sa-1'); expect(record).not.toBeNull(); - expect(record!.id).toBe('sa-1'); - expect(record!.userId).toBe('user-1'); - expect(record!.role).toBe('Research Analyst'); - expect(record!.toolsGranted).toEqual(['heartware_read', 'memory_recall']); - expect(record!.tierPreference).toBe('complex'); - expect(record!.status).toBe('active'); - expect(record!.performanceScore).toBe(0.5); - expect(record!.templateId).toBeNull(); + expect(record?.id).toBe('sa-1'); + expect(record?.userId).toBe('user-1'); + expect(record?.role).toBe('Research Analyst'); + expect(record?.toolsGranted).toEqual(['heartware_read', 'memory_recall']); + expect(record?.tierPreference).toBe('complex'); + expect(record?.status).toBe('active'); + expect(record?.performanceScore).toBe(0.5); + expect(record?.templateId).toBeNull(); db.close(); }); @@ -73,14 +73,26 @@ describe('sub_agents table', () => { }; db.saveSubAgent({ ...base, id: 'sa-1', userId: 'user-1', role: 'Agent A', status: 'active' }); - db.saveSubAgent({ ...base, id: 'sa-2', userId: 'user-1', role: 'Agent B', status: 'soft_deleted' }); + db.saveSubAgent({ + ...base, + id: 'sa-2', + userId: 'user-1', + role: 'Agent B', + status: 'soft_deleted', + }); db.saveSubAgent({ ...base, id: 'sa-3', userId: 'user-2', role: 'Agent C', status: 'active' }); - db.saveSubAgent({ ...base, id: 'sa-4', userId: 'user-1', role: 'Agent D', status: 'suspended' }); + db.saveSubAgent({ + ...base, + id: 'sa-4', + userId: 'user-1', + role: 'Agent D', + status: 'suspended', + }); const active = db.getActiveSubAgents('user-1'); expect(active.length).toBe(2); // active + suspended (not soft_deleted) - expect(active.some(a => a.id === 'sa-1')).toBe(true); - expect(active.some(a => a.id === 'sa-4')).toBe(true); + expect(active.some((a) => a.id === 'sa-1')).toBe(true); + expect(active.some((a) => a.id === 'sa-4')).toBe(true); db.close(); }); @@ -103,7 +115,14 @@ describe('sub_agents table', () => { }; db.saveSubAgent({ ...base, id: 'sa-1', userId: 'user-1', role: 'A', status: 'active' }); - db.saveSubAgent({ ...base, id: 'sa-2', userId: 'user-1', role: 'B', status: 'soft_deleted', deletedAt: now }); + db.saveSubAgent({ + ...base, + id: 'sa-2', + userId: 'user-1', + role: 'B', + status: 'soft_deleted', + deletedAt: now, + }); const withoutDeleted = db.getAllSubAgents('user-1', false); expect(withoutDeleted.length).toBe(1); @@ -144,10 +163,10 @@ describe('sub_agents table', () => { }); const updated = db.getSubAgent('sa-1'); - expect(updated!.status).toBe('soft_deleted'); - expect(updated!.performanceScore).toBe(0.8); - expect(updated!.totalTasks).toBe(5); - expect(updated!.successfulTasks).toBe(4); + expect(updated?.status).toBe('soft_deleted'); + expect(updated?.performanceScore).toBe(0.8); + expect(updated?.totalTasks).toBe(5); + expect(updated?.successfulTasks).toBe(4); db.close(); }); @@ -168,9 +187,30 @@ describe('sub_agents table', () => { lastActiveAt: now, }; - db.saveSubAgent({ ...base, id: 'sa-old', userId: 'u1', role: 'Old', status: 'soft_deleted', deletedAt: now - 100_000 }); - db.saveSubAgent({ ...base, id: 'sa-new', userId: 'u1', role: 'New', status: 'soft_deleted', deletedAt: now }); - db.saveSubAgent({ ...base, id: 'sa-active', userId: 'u1', role: 'Active', status: 'active', deletedAt: null }); + db.saveSubAgent({ + ...base, + id: 'sa-old', + userId: 'u1', + role: 'Old', + status: 'soft_deleted', + deletedAt: now - 100_000, + }); + db.saveSubAgent({ + ...base, + id: 'sa-new', + userId: 'u1', + role: 'New', + status: 'soft_deleted', + deletedAt: now, + }); + db.saveSubAgent({ + ...base, + id: 'sa-active', + userId: 'u1', + role: 'Active', + status: 'active', + deletedAt: null, + }); const deleted = db.deleteExpiredSubAgents(now - 50_000); expect(deleted).toBe(1); @@ -207,9 +247,9 @@ describe('role_templates table', () => { const template = db.getRoleTemplate('rt-1'); expect(template).not.toBeNull(); - expect(template!.name).toBe('Research Analyst'); - expect(template!.defaultTools).toEqual(['heartware_read']); - expect(template!.tags).toEqual(['research', 'analysis']); + expect(template?.name).toBe('Research Analyst'); + expect(template?.defaultTools).toEqual(['heartware_read']); + expect(template?.tags).toEqual(['research', 'analysis']); db.close(); }); @@ -268,10 +308,10 @@ describe('role_templates table', () => { }); const updated = db.getRoleTemplate('rt-1'); - expect(updated!.name).toBe('Technical Writer'); - expect(updated!.tags).toEqual(['writing', 'technical']); - expect(updated!.timesUsed).toBe(3); - expect(updated!.avgPerformance).toBe(0.85); + expect(updated?.name).toBe('Technical Writer'); + expect(updated?.tags).toEqual(['writing', 'technical']); + expect(updated?.timesUsed).toBe(3); + expect(updated?.avgPerformance).toBe(0.85); db.close(); }); @@ -324,9 +364,9 @@ describe('background_tasks table', () => { const task = db.getBackgroundTask('bt-1'); expect(task).not.toBeNull(); - expect(task!.id).toBe('bt-1'); - expect(task!.status).toBe('running'); - expect(task!.result).toBeNull(); + expect(task?.id).toBe('bt-1'); + expect(task?.status).toBe('running'); + expect(task?.result).toBeNull(); db.close(); }); @@ -350,9 +390,9 @@ describe('background_tasks table', () => { db.updateBackgroundTask('bt-1', 'completed', 'Task result here', now + 5000); const updated = db.getBackgroundTask('bt-1'); - expect(updated!.status).toBe('completed'); - expect(updated!.result).toBe('Task result here'); - expect(updated!.completedAt).toBe(now + 5000); + expect(updated?.status).toBe('completed'); + expect(updated?.result).toBe('Task result here'); + expect(updated?.completedAt).toBe(now + 5000); db.close(); }); @@ -368,10 +408,39 @@ describe('background_tasks table', () => { deliveredAt: null, }; - db.saveBackgroundTask({ ...base, id: 'bt-1', taskDescription: 'Running', status: 'running', result: null, completedAt: null }); - db.saveBackgroundTask({ ...base, id: 'bt-2', taskDescription: 'Completed', status: 'completed', result: 'Done!', completedAt: now + 1000 }); - db.saveBackgroundTask({ ...base, id: 'bt-3', taskDescription: 'Failed', status: 'failed', result: 'Error', completedAt: now + 2000 }); - db.saveBackgroundTask({ ...base, id: 'bt-4', taskDescription: 'Delivered', status: 'delivered', result: 'Old', completedAt: now, deliveredAt: now + 3000 }); + db.saveBackgroundTask({ + ...base, + id: 'bt-1', + taskDescription: 'Running', + status: 'running', + result: null, + completedAt: null, + }); + db.saveBackgroundTask({ + ...base, + id: 'bt-2', + taskDescription: 'Completed', + status: 'completed', + result: 'Done!', + completedAt: now + 1000, + }); + db.saveBackgroundTask({ + ...base, + id: 'bt-3', + taskDescription: 'Failed', + status: 'failed', + result: 'Error', + completedAt: now + 2000, + }); + db.saveBackgroundTask({ + ...base, + id: 'bt-4', + taskDescription: 'Delivered', + status: 'delivered', + result: 'Old', + completedAt: now, + deliveredAt: now + 3000, + }); const undelivered = db.getUndeliveredTasks('user-1'); expect(undelivered.length).toBe(2); @@ -400,8 +469,8 @@ describe('background_tasks table', () => { db.markTaskDelivered('bt-1'); const task = db.getBackgroundTask('bt-1'); - expect(task!.status).toBe('delivered'); - expect(task!.deliveredAt).not.toBeNull(); + expect(task?.status).toBe('delivered'); + expect(task?.deliveredAt).not.toBeNull(); db.close(); }); diff --git a/packages/delegation/tests/delegation.test.ts b/packages/delegation/tests/delegation.test.ts index e11e5f0..70c7f07 100644 --- a/packages/delegation/tests/delegation.test.ts +++ b/packages/delegation/tests/delegation.test.ts @@ -6,20 +6,16 @@ */ import { describe, expect, test } from 'bun:test'; -import { runSubAgent, createDelegationTool } from '../src/index.js'; -import type { Provider, Tool, Message, LLMResponse } from '@tinyclaw/types'; -import type { ProviderOrchestrator } from '@tinyclaw/router'; -import type { ProviderRegistry } from '@tinyclaw/router'; +import type { ProviderOrchestrator, ProviderRegistry } from '@tinyclaw/router'; +import type { LLMResponse, Message, Provider, Tool } from '@tinyclaw/types'; +import { createDelegationTool, runSubAgent } from '../src/index.js'; // --------------------------------------------------------------------------- // Mock helpers // --------------------------------------------------------------------------- /** Create a mock provider that returns the given responses in sequence. */ -function createMockProvider( - responses: LLMResponse[], - id = 'mock-provider', -): Provider { +function createMockProvider(responses: LLMResponse[], id = 'mock-provider'): Provider { let callIndex = 0; return { id, @@ -111,9 +107,7 @@ describe('runSubAgent', () => { const provider = createMockProvider([ { type: 'tool_calls', - toolCalls: [ - { id: 'tc-1', name: 'heartware_read', arguments: { filename: 'FRIEND.md' } }, - ], + toolCalls: [{ id: 'tc-1', name: 'heartware_read', arguments: { filename: 'FRIEND.md' } }], }, { type: 'text', content: 'Based on the file, here is the summary.' }, ]); @@ -160,9 +154,7 @@ describe('runSubAgent', () => { const provider = createMockProvider( Array.from({ length: 15 }, () => ({ type: 'tool_calls' as const, - toolCalls: [ - { id: `tc-${Math.random()}`, name: 'heartware_list', arguments: {} }, - ], + toolCalls: [{ id: `tc-${Math.random()}`, name: 'heartware_list', arguments: {} }], })), ); @@ -209,9 +201,7 @@ describe('runSubAgent', () => { const provider = createMockProvider([ { type: 'tool_calls', - toolCalls: [ - { id: 'tc-1', name: 'nonexistent_tool', arguments: {} }, - ], + toolCalls: [{ id: 'tc-1', name: 'nonexistent_tool', arguments: {} }], }, { type: 'text', content: 'Tool was not found, proceeding without it.' }, ]); @@ -240,9 +230,7 @@ describe('runSubAgent', () => { const provider = createMockProvider([ { type: 'tool_calls', - toolCalls: [ - { id: 'tc-1', name: 'failing_tool', arguments: {} }, - ], + toolCalls: [{ id: 'tc-1', name: 'failing_tool', arguments: {} }], }, { type: 'text', content: 'Recovered from tool error.' }, ]); @@ -282,9 +270,7 @@ describe('runSubAgent', () => { }); test('handles empty content in text response', async () => { - const provider = createMockProvider([ - { type: 'text', content: '' }, - ]); + const provider = createMockProvider([{ type: 'text', content: '' }]); const result = await runSubAgent({ task: 'Empty response task', @@ -361,8 +347,8 @@ describe('createDelegationTool', () => { createMockTool('heartware_list'), createMockTool('heartware_write'), // NOT in default safe set createMockTool('memory_recall'), - createMockTool('memory_add'), // NOT in default safe set - createMockTool('config_set'), // NOT in default safe set + createMockTool('memory_add'), // NOT in default safe set + createMockTool('config_set'), // NOT in default safe set ]; const tool = createDelegationTool({ orchestrator, allTools }); diff --git a/packages/delegation/tests/lifecycle.test.ts b/packages/delegation/tests/lifecycle.test.ts index e6d97c4..f75d02a 100644 --- a/packages/delegation/tests/lifecycle.test.ts +++ b/packages/delegation/tests/lifecycle.test.ts @@ -5,8 +5,8 @@ import { describe, expect, test } from 'bun:test'; import { createDatabase } from '@tinyclaw/core'; -import { createLifecycleManager } from '../src/index.js'; import type { OrientationContext } from '../src/index.js'; +import { createLifecycleManager } from '../src/index.js'; function createTestDb() { return createDatabase(':memory:'); @@ -53,8 +53,8 @@ describe('Lifecycle Manager', () => { const fetched = lm.get(agent.id); expect(fetched).not.toBeNull(); - expect(fetched!.id).toBe(agent.id); - expect(fetched!.role).toBe('Writer'); + expect(fetched?.id).toBe(agent.id); + expect(fetched?.role).toBe('Writer'); db.close(); }); @@ -65,13 +65,18 @@ describe('Lifecycle Manager', () => { lm.create({ userId: 'u1', role: 'Agent A', toolsGranted: [], orientation: ORIENTATION }); lm.create({ userId: 'u1', role: 'Agent B', toolsGranted: [], orientation: ORIENTATION }); - const agentC = lm.create({ userId: 'u1', role: 'Agent C', toolsGranted: [], orientation: ORIENTATION }); + const agentC = lm.create({ + userId: 'u1', + role: 'Agent C', + toolsGranted: [], + orientation: ORIENTATION, + }); lm.suspend(agentC.id); const active = lm.listActive('u1'); expect(active.length).toBe(3); // All 3 still listed (suspended is visible) - expect(active.some(a => a.status === 'suspended')).toBe(true); + expect(active.some((a) => a.status === 'suspended')).toBe(true); db.close(); }); @@ -80,13 +85,23 @@ describe('Lifecycle Manager', () => { const db = createTestDb(); const lm = createLifecycleManager(db); - lm.create({ userId: 'u1', role: 'Technical Research Analyst', toolsGranted: [], orientation: ORIENTATION }); - lm.create({ userId: 'u1', role: 'Creative Writer', toolsGranted: [], orientation: ORIENTATION }); + lm.create({ + userId: 'u1', + role: 'Technical Research Analyst', + toolsGranted: [], + orientation: ORIENTATION, + }); + lm.create({ + userId: 'u1', + role: 'Creative Writer', + toolsGranted: [], + orientation: ORIENTATION, + }); // Should match the research analyst const match = lm.findReusable('u1', 'Research Analyst'); expect(match).not.toBeNull(); - expect(match!.role).toBe('Technical Research Analyst'); + expect(match?.role).toBe('Technical Research Analyst'); // No match for unrelated role const noMatch = lm.findReusable('u1', 'Database Administrator Expert'); @@ -99,16 +114,21 @@ describe('Lifecycle Manager', () => { const db = createTestDb(); const lm = createLifecycleManager(db); - const agent = lm.create({ userId: 'u1', role: 'Test', toolsGranted: [], orientation: ORIENTATION }); + const agent = lm.create({ + userId: 'u1', + role: 'Test', + toolsGranted: [], + orientation: ORIENTATION, + }); lm.recordTaskResult(agent.id, true); lm.recordTaskResult(agent.id, true); lm.recordTaskResult(agent.id, false); const updated = lm.get(agent.id); - expect(updated!.totalTasks).toBe(3); - expect(updated!.successfulTasks).toBe(2); - expect(updated!.performanceScore).toBeCloseTo(2 / 3); + expect(updated?.totalTasks).toBe(3); + expect(updated?.successfulTasks).toBe(2); + expect(updated?.performanceScore).toBeCloseTo(2 / 3); db.close(); }); @@ -117,18 +137,23 @@ describe('Lifecycle Manager', () => { const db = createTestDb(); const lm = createLifecycleManager(db); - const agent = lm.create({ userId: 'u1', role: 'Agent', toolsGranted: [], orientation: ORIENTATION }); + const agent = lm.create({ + userId: 'u1', + role: 'Agent', + toolsGranted: [], + orientation: ORIENTATION, + }); // Suspend lm.suspend(agent.id); const suspended = lm.get(agent.id); - expect(suspended!.status).toBe('suspended'); + expect(suspended?.status).toBe('suspended'); // Revive const revived = lm.revive(agent.id); expect(revived).not.toBeNull(); - expect(revived!.status).toBe('active'); - expect(revived!.deletedAt).toBeNull(); + expect(revived?.status).toBe('active'); + expect(revived?.deletedAt).toBeNull(); db.close(); }); @@ -137,7 +162,12 @@ describe('Lifecycle Manager', () => { const db = createTestDb(); const lm = createLifecycleManager(db); - const agent = lm.create({ userId: 'u1', role: 'Agent', toolsGranted: [], orientation: ORIENTATION }); + const agent = lm.create({ + userId: 'u1', + role: 'Agent', + toolsGranted: [], + orientation: ORIENTATION, + }); const result = lm.revive(agent.id); expect(result).toBeNull(); @@ -148,13 +178,18 @@ describe('Lifecycle Manager', () => { const db = createTestDb(); const lm = createLifecycleManager(db); - const agent = lm.create({ userId: 'u1', role: 'Technical Research Analyst', toolsGranted: [], orientation: ORIENTATION }); + const agent = lm.create({ + userId: 'u1', + role: 'Technical Research Analyst', + toolsGranted: [], + orientation: ORIENTATION, + }); lm.suspend(agent.id); const match = lm.findReusable('u1', 'Research Analyst'); expect(match).not.toBeNull(); - expect(match!.id).toBe(agent.id); - expect(match!.status).toBe('suspended'); + expect(match?.id).toBe(agent.id); + expect(match?.status).toBe('suspended'); db.close(); }); @@ -163,13 +198,18 @@ describe('Lifecycle Manager', () => { const db = createTestDb(); const lm = createLifecycleManager(db); - const agent = lm.create({ userId: 'u1', role: 'Technical Research Analyst', toolsGranted: [], orientation: ORIENTATION }); + const agent = lm.create({ + userId: 'u1', + role: 'Technical Research Analyst', + toolsGranted: [], + orientation: ORIENTATION, + }); lm.dismiss(agent.id); const match = lm.findReusable('u1', 'Research Analyst'); expect(match).not.toBeNull(); - expect(match!.id).toBe(agent.id); - expect(match!.status).toBe('soft_deleted'); + expect(match?.id).toBe(agent.id); + expect(match?.status).toBe('soft_deleted'); db.close(); }); @@ -178,7 +218,12 @@ describe('Lifecycle Manager', () => { const db = createTestDb(); const lm = createLifecycleManager(db); - const agent = lm.create({ userId: 'u1', role: 'Doomed', toolsGranted: [], orientation: ORIENTATION }); + const agent = lm.create({ + userId: 'u1', + role: 'Doomed', + toolsGranted: [], + orientation: ORIENTATION, + }); // Save some messages lm.saveMessage(agent.id, 'user', 'Task 1'); @@ -196,7 +241,12 @@ describe('Lifecycle Manager', () => { const db = createTestDb(); const lm = createLifecycleManager(db); - const agent = lm.create({ userId: 'u1', role: 'Messenger', toolsGranted: [], orientation: ORIENTATION }); + const agent = lm.create({ + userId: 'u1', + role: 'Messenger', + toolsGranted: [], + orientation: ORIENTATION, + }); lm.saveMessage(agent.id, 'user', 'Hello sub-agent'); lm.saveMessage(agent.id, 'assistant', 'Hello! How can I help?'); @@ -230,10 +280,18 @@ describe('Lifecycle Manager', () => { const db = createTestDb(); const lm = createLifecycleManager(db); - const agent = lm.create({ userId: 'u1', role: 'Old Agent', toolsGranted: [], orientation: ORIENTATION }); + const agent = lm.create({ + userId: 'u1', + role: 'Old Agent', + toolsGranted: [], + orientation: ORIENTATION, + }); // Manually set deletedAt far in the past via db - db.updateSubAgent(agent.id, { status: 'soft_deleted', deletedAt: Date.now() - 15 * 24 * 60 * 60 * 1000 }); + db.updateSubAgent(agent.id, { + status: 'soft_deleted', + deletedAt: Date.now() - 15 * 24 * 60 * 60 * 1000, + }); const cleaned = lm.cleanup(); // 14-day default retention expect(cleaned).toBe(1); diff --git a/packages/delegation/tests/templates.test.ts b/packages/delegation/tests/templates.test.ts index 2f1a1e6..e795f3c 100644 --- a/packages/delegation/tests/templates.test.ts +++ b/packages/delegation/tests/templates.test.ts @@ -53,12 +53,12 @@ describe('Template Manager', () => { // Should match research analyst const match = tm.findBestMatch('u1', 'research technical analysis task'); expect(match).not.toBeNull(); - expect(match!.name).toBe('Research Analyst'); + expect(match?.name).toBe('Research Analyst'); // Should match creative writer const match2 = tm.findBestMatch('u1', 'write creative blog post'); expect(match2).not.toBeNull(); - expect(match2!.name).toBe('Creative Writer'); + expect(match2?.name).toBe('Creative Writer'); db.close(); }); @@ -108,8 +108,8 @@ describe('Template Manager', () => { }); expect(updated).not.toBeNull(); - expect(updated!.name).toBe('Technical Writer'); - expect(updated!.tags).toEqual(['writing', 'technical', 'documentation']); + expect(updated?.name).toBe('Technical Writer'); + expect(updated?.tags).toEqual(['writing', 'technical', 'documentation']); db.close(); }); diff --git a/packages/delegation/tests/timeout-estimator.test.ts b/packages/delegation/tests/timeout-estimator.test.ts index 15cd222..4a5001b 100644 --- a/packages/delegation/tests/timeout-estimator.test.ts +++ b/packages/delegation/tests/timeout-estimator.test.ts @@ -1,24 +1,35 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { existsSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { createDatabase } from '@tinyclaw/core'; -import { createTimeoutEstimator, type TimeoutEstimator } from '../src/index.js'; import type { Database } from '@tinyclaw/types'; -import { unlinkSync, existsSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; +import { createTimeoutEstimator, type TimeoutEstimator } from '../src/index.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function createTestDb(): { db: Database; path: string } { - const path = join(tmpdir(), `tinyclaw-test-timeout-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + const path = join( + tmpdir(), + `tinyclaw-test-timeout-${Date.now()}-${Math.random().toString(36).slice(2)}.db`, + ); const db = createDatabase(path); return { db, path }; } function cleanupDb(db: Database, path: string): void { - try { db.close(); } catch { /* ignore */ } - try { if (existsSync(path)) unlinkSync(path); } catch { /* ignore */ } + try { + db.close(); + } catch { + /* ignore */ + } + try { + if (existsSync(path)) unlinkSync(path); + } catch { + /* ignore */ + } } // --------------------------------------------------------------------------- @@ -59,7 +70,9 @@ describe('TimeoutEstimator', () => { }); it('classifies analysis tasks', () => { - expect(estimator.classifyTask('Analyze the sales data and generate a report')).toBe('analysis'); + expect(estimator.classifyTask('Analyze the sales data and generate a report')).toBe( + 'analysis', + ); expect(estimator.classifyTask('Evaluate the benchmark results')).toBe('analysis'); }); diff --git a/packages/delegation/tests/tools.test.ts b/packages/delegation/tests/tools.test.ts index 1c06cf3..6055c62 100644 --- a/packages/delegation/tests/tools.test.ts +++ b/packages/delegation/tests/tools.test.ts @@ -5,19 +5,22 @@ import { describe, expect, test } from 'bun:test'; import { createDatabase } from '@tinyclaw/core'; import { createSessionQueue } from '@tinyclaw/queue'; +import type { ProviderOrchestrator, ProviderRegistry } from '@tinyclaw/router'; +import type { + LearnedContext, + LearningEngine, + LLMResponse, + Message, + Provider, + Tool, +} from '@tinyclaw/types'; import { createDelegationTools } from '../src/index.js'; -import type { Provider, Tool, Message, LLMResponse, LearningEngine, LearnedContext } from '@tinyclaw/types'; -import type { ProviderOrchestrator } from '@tinyclaw/router'; -import type { ProviderRegistry } from '@tinyclaw/router'; // --------------------------------------------------------------------------- // Mock helpers // --------------------------------------------------------------------------- -function createMockProvider( - responses: LLMResponse[], - id = 'mock-provider', -): Provider { +function createMockProvider(responses: LLMResponse[], id = 'mock-provider'): Provider { let callIndex = 0; return { id, @@ -179,8 +182,8 @@ describe('delegate_task', () => { // Task stats updated by background runner const updated = result.lifecycle.get(agents[0].id); - expect(updated!.totalTasks).toBe(1); - expect(updated!.successfulTasks).toBe(1); + expect(updated?.totalTasks).toBe(1); + expect(updated?.successfulTasks).toBe(1); db.close(); }); @@ -217,15 +220,13 @@ describe('delegate_task', () => { await Bun.sleep(400); const updated = result.lifecycle.get(agents[0].id); - expect(updated!.totalTasks).toBe(2); + expect(updated?.totalTasks).toBe(2); db.close(); }); test('auto-creates template on success', async () => { - const { result, db } = setup([ - { type: 'text', content: 'Task done.' }, - ]); + const { result, db } = setup([{ type: 'text', content: 'Task done.' }]); const tool = findTool(result.tools, 'delegate_task'); @@ -292,9 +293,7 @@ describe('delegate_tasks', () => { }); test('skips entries with missing task or role', async () => { - const { result, db } = setup([ - { type: 'text', content: 'Done.' }, - ]); + const { result, db } = setup([{ type: 'text', content: 'Done.' }]); const tool = findTool(result.tools, 'delegate_tasks'); const output = await tool.execute({ @@ -390,8 +389,16 @@ describe('list_sub_agents', () => { const delegateTool = findTool(result.tools, 'delegate_task'); // Use very distinct roles so they won't be reused - await delegateTool.execute({ task: 'Research quantum physics', role: 'Quantum Physics Researcher', user_id: 'u1' }); - await delegateTool.execute({ task: 'Write poetry about nature', role: 'Creative Poetry Writer', user_id: 'u1' }); + await delegateTool.execute({ + task: 'Research quantum physics', + role: 'Quantum Physics Researcher', + user_id: 'u1', + }); + await delegateTool.execute({ + task: 'Write poetry about nature', + role: 'Creative Poetry Writer', + user_id: 'u1', + }); const listTool = findTool(result.tools, 'list_sub_agents'); const output = await listTool.execute({ user_id: 'u1', include_deleted: true }); @@ -417,9 +424,7 @@ describe('list_sub_agents', () => { describe('manage_sub_agent', () => { test('dismiss and revive cycle', async () => { - const { result, db } = setup([ - { type: 'text', content: 'Done.' }, - ]); + const { result, db } = setup([{ type: 'text', content: 'Done.' }]); // Create an agent const delegateTool = findTool(result.tools, 'delegate_task'); @@ -444,9 +449,7 @@ describe('manage_sub_agent', () => { }); test('kill permanently removes agent', async () => { - const { result, db } = setup([ - { type: 'text', content: 'Done.' }, - ]); + const { result, db } = setup([{ type: 'text', content: 'Done.' }]); const delegateTool = findTool(result.tools, 'delegate_task'); await delegateTool.execute({ task: 'Task', role: 'Doomed', user_id: 'u1' }); @@ -476,9 +479,7 @@ describe('manage_sub_agent', () => { describe('manage_template', () => { test('list returns all templates', async () => { - const { result, db } = setup([ - { type: 'text', content: 'Done.' }, - ]); + const { result, db } = setup([{ type: 'text', content: 'Done.' }]); // Create a template via delegation (auto-create on background success) const delegateTool = findTool(result.tools, 'delegate_task'); @@ -496,9 +497,7 @@ describe('manage_template', () => { }); test('delete removes template', async () => { - const { result, db } = setup([ - { type: 'text', content: 'Done.' }, - ]); + const { result, db } = setup([{ type: 'text', content: 'Done.' }]); const delegateTool = findTool(result.tools, 'delegate_task'); await delegateTool.execute({ task: 'Research AI', role: 'Researcher', user_id: 'u1' }); @@ -565,7 +564,7 @@ describe('delegate_to_existing', () => { // Agent should have 2 completed tasks const updated = result.lifecycle.get(agentId); - expect(updated!.totalTasks).toBe(2); + expect(updated?.totalTasks).toBe(2); db.close(); }); diff --git a/packages/gateway/src/index.ts b/packages/gateway/src/index.ts index 35e543a..0206e9d 100644 --- a/packages/gateway/src/index.ts +++ b/packages/gateway/src/index.ts @@ -23,10 +23,10 @@ import { logger } from '@tinyclaw/logger'; import type { + ChannelSender, + OutboundDeliveryResult, OutboundGateway, OutboundMessage, - OutboundDeliveryResult, - ChannelSender, } from '@tinyclaw/types'; // --------------------------------------------------------------------------- @@ -88,10 +88,7 @@ export function createGateway(): OutboundGateway { } }, - async send( - userId: string, - message: OutboundMessage, - ): Promise { + async send(userId: string, message: OutboundMessage): Promise { const prefix = resolvePrefix(userId); if (!prefix) { @@ -143,9 +140,7 @@ export function createGateway(): OutboundGateway { } }, - async broadcast( - message: OutboundMessage, - ): Promise { + async broadcast(message: OutboundMessage): Promise { const results: OutboundDeliveryResult[] = []; for (const [prefix, sender] of senders) { diff --git a/packages/gateway/tests/gateway.test.ts b/packages/gateway/tests/gateway.test.ts index c4e34d7..113af57 100644 --- a/packages/gateway/tests/gateway.test.ts +++ b/packages/gateway/tests/gateway.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, beforeEach } from 'bun:test'; -import { createGateway } from '../src/index'; +import { beforeEach, describe, expect, it } from 'bun:test'; import type { ChannelSender, OutboundMessage } from '@tinyclaw/types'; +import { createGateway } from '../src/index'; // --------------------------------------------------------------------------- // Helpers @@ -15,7 +15,10 @@ function createMessage(overrides: Partial = {}): OutboundMessag }; } -function createMockSender(name: string, overrides: Partial = {}): ChannelSender & { calls: Array<{ userId: string; message: OutboundMessage }> } { +function createMockSender( + name: string, + overrides: Partial = {}, +): ChannelSender & { calls: Array<{ userId: string; message: OutboundMessage }> } { const calls: Array<{ userId: string; message: OutboundMessage }> = []; return { name, @@ -164,7 +167,14 @@ describe('OutboundGateway', () => { const sender = createMockSender('Web UI'); gateway.register('web', sender); - const sources = ['background_task', 'sub_agent', 'reminder', 'pulse', 'system', 'agent'] as const; + const sources = [ + 'background_task', + 'sub_agent', + 'reminder', + 'pulse', + 'system', + 'agent', + ] as const; for (const source of sources) { await gateway.send('web:owner', createMessage({ source })); } @@ -181,12 +191,16 @@ describe('OutboundGateway', () => { const webSender: ChannelSender = { name: 'Web UI', async send() {}, - async broadcast() { broadcastCalls.push('web'); }, + async broadcast() { + broadcastCalls.push('web'); + }, }; const discordSender: ChannelSender = { name: 'Discord', async send() {}, - async broadcast() { broadcastCalls.push('discord'); }, + async broadcast() { + broadcastCalls.push('discord'); + }, }; gateway.register('web', webSender); @@ -195,7 +209,7 @@ describe('OutboundGateway', () => { const results = await gateway.broadcast(createMessage()); expect(results.length).toBe(2); - expect(results.every(r => r.success)).toBe(true); + expect(results.every((r) => r.success)).toBe(true); expect(broadcastCalls).toContain('web'); expect(broadcastCalls).toContain('discord'); }); @@ -218,7 +232,9 @@ describe('OutboundGateway', () => { const badSender: ChannelSender = { name: 'Bad Channel', async send() {}, - async broadcast() { throw new Error('Broadcast failed'); }, + async broadcast() { + throw new Error('Broadcast failed'); + }, }; gateway.register('good', goodSender); @@ -227,8 +243,8 @@ describe('OutboundGateway', () => { const results = await gateway.broadcast(createMessage()); expect(results.length).toBe(2); - const goodResult = results.find(r => r.channel === 'good'); - const badResult = results.find(r => r.channel === 'bad'); + const goodResult = results.find((r) => r.channel === 'good'); + const badResult = results.find((r) => r.channel === 'bad'); expect(goodResult?.success).toBe(true); expect(badResult?.success).toBe(false); expect(badResult?.error).toBe('Broadcast failed'); diff --git a/packages/heartware/src/audit.ts b/packages/heartware/src/audit.ts index bdb9020..553c472 100644 --- a/packages/heartware/src/audit.ts +++ b/packages/heartware/src/audit.ts @@ -8,11 +8,11 @@ * - Non-blocking failures (logs to console if file write fails) */ -import { appendFileSync, mkdirSync } from 'fs'; -import { join } from 'path'; -import { createHash } from 'crypto'; -import type { AuditLogEntry } from './types.js'; +import { createHash } from 'node:crypto'; +import { appendFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; import type { ShieldDecision } from '@tinyclaw/types'; +import type { AuditLogEntry } from './types.js'; /** * Audit logger for heartware operations @@ -34,7 +34,7 @@ export class AuditLogger { // Ensure audit directory exists try { mkdirSync(auditDir, { recursive: true }); - } catch (err) { + } catch (_err) { // Directory might already exist - this is fine } } @@ -46,17 +46,17 @@ export class AuditLogger { * but the original operation is not blocked */ log(entry: AuditLogEntry): void { - const logLine = JSON.stringify({ + const logLine = `${JSON.stringify({ ...entry, - timestamp: entry.timestamp || new Date().toISOString() - }) + '\n'; + timestamp: entry.timestamp || new Date().toISOString(), + })}\n`; try { // Append-only write with explicit append flag // This makes tampering harder than with regular write appendFileSync(this.auditLogPath, logLine, { encoding: 'utf-8', - flag: 'a' // Append mode + flag: 'a', // Append mode }); } catch (err) { // CRITICAL: Audit logging failure should not block operations @@ -74,7 +74,7 @@ export class AuditLogger { operation: AuditLogEntry['operation'], file: string, contentHash?: string, - previousHash?: string + previousHash?: string, ): void { this.log({ timestamp: new Date().toISOString(), @@ -83,7 +83,7 @@ export class AuditLogger { file, success: true, contentHash, - previousHash + previousHash, }); } @@ -94,7 +94,7 @@ export class AuditLogger { userId: string, operation: AuditLogEntry['operation'], file: string, - error: Error + error: Error, ): void { this.log({ timestamp: new Date().toISOString(), @@ -103,7 +103,7 @@ export class AuditLogger { file, success: false, errorCode: (error as any).code, - errorMessage: error.message + errorMessage: error.message, }); } @@ -115,7 +115,7 @@ export class AuditLogger { operation: AuditLogEntry['operation'], file: string, success: boolean, - metadata: Record + metadata: Record, ): void { this.log({ timestamp: new Date().toISOString(), @@ -123,7 +123,7 @@ export class AuditLogger { operation, file, success, - metadata + metadata, }); } @@ -147,7 +147,7 @@ export class AuditLogger { outcome: 'blocked' | 'approved' | 'denied' | 'logged', userId?: string, ): void { - const logLine = JSON.stringify({ + const logLine = `${JSON.stringify({ timestamp: new Date().toISOString(), type: 'shield', action: decision.action, @@ -159,7 +159,7 @@ export class AuditLogger { matchValue: decision.matchValue, reason: decision.reason, userId: userId ?? 'unknown', - }) + '\n'; + })}\n`; try { appendFileSync(this.auditLogPath, logLine, { diff --git a/packages/heartware/src/backup.ts b/packages/heartware/src/backup.ts index ec0dae3..13eef87 100644 --- a/packages/heartware/src/backup.ts +++ b/packages/heartware/src/backup.ts @@ -8,8 +8,16 @@ * - Content hashing for integrity verification */ -import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from 'fs'; -import { join, basename } from 'path'; +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + unlinkSync, +} from 'node:fs'; +import { basename, join } from 'node:path'; import { computeContentHash } from './audit.js'; import type { BackupMetadata } from './types.js'; @@ -33,7 +41,7 @@ export class BackupManager { // Create backup directory if it doesn't exist try { mkdirSync(this.backupDir, { recursive: true }); - } catch (err) { + } catch (_err) { // Directory might already exist - this is fine } } @@ -73,7 +81,7 @@ export class BackupManager { backupPath, timestamp, contentHash, - size: stats.size + size: stats.size, }; } catch (err) { // Log error but don't block the operation @@ -89,10 +97,10 @@ export class BackupManager { try { const files = readdirSync(this.backupDir); return files - .filter(f => f.startsWith(`${filename}.`) && f.endsWith('.bak')) + .filter((f) => f.startsWith(`${filename}.`) && f.endsWith('.bak')) .sort() .reverse(); // Most recent first - } catch (err) { + } catch (_err) { return []; } } @@ -120,7 +128,7 @@ export class BackupManager { backupPath, timestamp, contentHash: computeContentHash(content), - size: stats.size + size: stats.size, }; } catch (err) { console.error(`[BACKUP ERROR] Failed to get metadata for ${backupFilename}:`, err); @@ -198,7 +206,7 @@ export class BackupManager { } return totalSize; - } catch (err) { + } catch (_err) { return 0; } } diff --git a/packages/heartware/src/errors.ts b/packages/heartware/src/errors.ts index 6f746f5..e351e33 100644 --- a/packages/heartware/src/errors.ts +++ b/packages/heartware/src/errors.ts @@ -25,11 +25,7 @@ export class HeartwareSecurityError extends Error { */ public readonly details?: Record; - constructor( - code: SecurityErrorCode, - message: string, - details?: Record - ) { + constructor(code: SecurityErrorCode, message: string, details?: Record) { super(message); this.name = 'HeartwareSecurityError'; this.code = code; @@ -50,16 +46,14 @@ export class HeartwareSecurityError extends Error { code: this.code, message: this.message, // Only include non-sensitive details - details: this.sanitizeDetails(this.details) + details: this.sanitizeDetails(this.details), }; } /** * Sanitize details to prevent leaking absolute paths or sensitive info */ - private sanitizeDetails( - details?: Record - ): Record | undefined { + private sanitizeDetails(details?: Record): Record | undefined { if (!details) return undefined; const sanitized: Record = {}; @@ -79,18 +73,13 @@ export class HeartwareSecurityError extends Error { /** * Check if an error is a HeartwareSecurityError */ -export function isHeartwareSecurityError( - error: unknown -): error is HeartwareSecurityError { +export function isHeartwareSecurityError(error: unknown): error is HeartwareSecurityError { return error instanceof HeartwareSecurityError; } /** * Check if an error is a specific security error code */ -export function isSecurityErrorCode( - error: unknown, - code: SecurityErrorCode -): boolean { +export function isSecurityErrorCode(error: unknown, code: SecurityErrorCode): boolean { return isHeartwareSecurityError(error) && error.code === code; } diff --git a/packages/heartware/src/index.ts b/packages/heartware/src/index.ts index fcab55c..fea37ab 100644 --- a/packages/heartware/src/index.ts +++ b/packages/heartware/src/index.ts @@ -26,74 +26,75 @@ * ``` */ +// Security components (for advanced usage/testing) +export { AuditLogger, computeContentHash, verifyContentHash } from './audit.js'; +export { BackupManager } from './backup.js'; +// Errors +export { + HeartwareSecurityError, + isHeartwareSecurityError, + isSecurityErrorCode, +} from './errors.js'; +export { + loadHeartwareContext, + loadMemoryByDate, + loadMemoryRange, + loadShieldContent, +} from './loader.js'; // Core exports export { HeartwareManager } from './manager.js'; -export { createHeartwareTools } from './tools.js'; -export { loadHeartwareContext, loadShieldContent, loadMemoryByDate, loadMemoryRange } from './loader.js'; - -// Soul Generator -export { - generateSoul, - generateSoulTraits, - renderSoulMarkdown, - generateRandomSeed, - parseSeed -} from './soul-generator.js'; - +export type { MetaFetchOptions } from './meta.js'; // Creator Meta export { + DEFAULT_META_URL, fetchCreatorMeta, loadCachedCreatorMeta, - DEFAULT_META_URL, META_CACHE_FILE, } from './meta.js'; -export type { MetaFetchOptions } from './meta.js'; - -// Security components (for advanced usage/testing) -export { AuditLogger, computeContentHash, verifyContentHash } from './audit.js'; -export { BackupManager } from './backup.js'; export { RateLimiter } from './rate-limiter.js'; export { - validatePath, - validateContent, - validateFileSize, + getAllowedFiles, + getImmutableFiles, + getMemoryFilePattern, isAllowedFile, isImmutableFile, - getImmutableFiles, - getAllowedFiles, - getMemoryFilePattern + validateContent, + validateFileSize, + validatePath, } from './sandbox.js'; +// Soul Generator +export { + generateRandomSeed, + generateSoul, + generateSoulTraits, + parseSeed, + renderSoulMarkdown, +} from './soul-generator.js'; // Templates -export { getTemplate, hasTemplate, getAllTemplates } from './templates.js'; - -// Errors -export { - HeartwareSecurityError, - isHeartwareSecurityError, - isSecurityErrorCode -} from './errors.js'; +export { getAllTemplates, getTemplate, hasTemplate } from './templates.js'; +export { createHeartwareTools } from './tools.js'; // Types export type { - HeartwareConfig, - PathValidationResult, - ContentValidationResult, - ContentValidationRule, + AllowedFile, AuditLogEntry, BackupMetadata, - RateLimitConfig, - AllowedFile, - SecurityErrorCode, - SearchResult, // Soul types BigFiveTraits, + CharacterFlavor, CommunicationStyle, + ContentValidationResult, + ContentValidationRule, + HeartwareConfig, HumorType, - SoulPreferences, - CharacterFlavor, InteractionStyle, OriginStory, + PathValidationResult, + RateLimitConfig, + SearchResult, + SecurityErrorCode, + SoulGenerationResult, + SoulPreferences, SoulTraits, - SoulGenerationResult } from './types.js'; diff --git a/packages/heartware/src/loader.ts b/packages/heartware/src/loader.ts index 89ca379..c2eaf14 100644 --- a/packages/heartware/src/loader.ts +++ b/packages/heartware/src/loader.ts @@ -15,7 +15,6 @@ */ import type { HeartwareManager } from './manager.js'; -import { generateSoul } from './soul-generator.js'; import { loadCachedCreatorMeta } from './meta.js'; /** Label used for creator meta in heartware context */ @@ -27,17 +26,15 @@ const META_CACHE_LABEL = 'CREATOR.md — About My Creator'; * @param manager - Initialized HeartwareManager instance * @returns Formatted context string for system prompt */ -export async function loadHeartwareContext( - manager: HeartwareManager -): Promise { +export async function loadHeartwareContext(manager: HeartwareManager): Promise { const loadOrder = [ - 'SOUL.md', // Load first - defines personality - 'IDENTITY.md', // Who the agent is - 'FRIEND.md', // Who the owner is - 'FRIENDS.md', // People I've met (non-owner friends) - 'AGENTS.md', // Operating instructions - 'TOOLS.md', // Tool usage notes - 'SHIELD.md' // Runtime security policy + 'SOUL.md', // Load first - defines personality + 'IDENTITY.md', // Who the agent is + 'FRIEND.md', // Who the owner is + 'FRIENDS.md', // People I've met (non-owner friends) + 'AGENTS.md', // Operating instructions + 'TOOLS.md', // Tool usage notes + 'SHIELD.md', // Runtime security policy ]; let context = '\n\n=== HEARTWARE CONFIGURATION ===\n'; @@ -47,7 +44,8 @@ export async function loadHeartwareContext( const seed = await manager.getSeed(); if (seed !== undefined) { context += `\n> SOUL.md is immutable — permanently generated from soul seed \`${seed}\`.`; - context += '\n> Your personality traits, preferences, and quirks are permanent. Embrace them.\n'; + context += + '\n> Your personality traits, preferences, and quirks are permanent. Embrace them.\n'; } } catch { // Seed might not exist yet @@ -58,7 +56,7 @@ export async function loadHeartwareContext( try { const content = await manager.read(file); context += `\n\n--- ${file} ---\n${content}`; - } catch (err) { + } catch (_err) { // File might not exist yet (first run) context += `\n\n--- ${file} ---\n[Not configured yet]`; } @@ -70,7 +68,7 @@ export async function loadHeartwareContext( if (recentMemories) { context += `\n\n--- Recent Memory ---\n${recentMemories}`; } - } catch (err) { + } catch (_err) { // No recent memories - this is fine } @@ -99,9 +97,7 @@ export async function loadHeartwareContext( * @param manager - Initialized HeartwareManager instance * @returns Raw SHIELD.md content string, or empty string if not found */ -export async function loadShieldContent( - manager: HeartwareManager -): Promise { +export async function loadShieldContent(manager: HeartwareManager): Promise { try { return await manager.read('SHIELD.md'); } catch { @@ -117,10 +113,7 @@ export async function loadShieldContent( * @param daysBack - Number of days to look back (0 = today only) * @returns Combined memory content or empty string */ -async function loadRecentMemories( - manager: HeartwareManager, - daysBack: number -): Promise { +async function loadRecentMemories(manager: HeartwareManager, daysBack: number): Promise { let output = ''; const now = new Date(); @@ -133,10 +126,7 @@ async function loadRecentMemories( try { const content = await manager.read(filename); output += `\n${content}\n`; - } catch (err) { - // File doesn't exist - skip - continue; - } + } catch (_err) {} } return output; @@ -151,12 +141,12 @@ async function loadRecentMemories( */ export async function loadMemoryByDate( manager: HeartwareManager, - date: string + date: string, ): Promise { try { const content = await manager.read(`memory/${date}.md`); return content; - } catch (err) { + } catch (_err) { return null; } } @@ -172,13 +162,13 @@ export async function loadMemoryByDate( export async function loadMemoryRange( manager: HeartwareManager, startDate: string, - endDate: string + endDate: string, ): Promise> { const memories: Array<{ date: string; content: string }> = []; const start = new Date(startDate); const end = new Date(endDate); - let current = new Date(start); + const current = new Date(start); while (current <= end) { const dateStr = current.toISOString().split('T')[0]; const content = await loadMemoryByDate(manager, dateStr); diff --git a/packages/heartware/src/manager.ts b/packages/heartware/src/manager.ts index a8a0dfb..ef3a5a2 100644 --- a/packages/heartware/src/manager.ts +++ b/packages/heartware/src/manager.ts @@ -10,17 +10,17 @@ * All operations go through these layers in the correct order */ -import { readFile, writeFile, readdir, mkdir } from 'fs/promises'; -import { existsSync } from 'fs'; -import { join } from 'path'; -import { validatePath, validateContent, validateFileSize } from './sandbox.js'; +import { existsSync } from 'node:fs'; +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { logger } from '@tinyclaw/logger'; import { AuditLogger, computeContentHash } from './audit.js'; -import { RateLimiter } from './rate-limiter.js'; import { BackupManager } from './backup.js'; -import { getTemplate } from './templates.js'; -import { generateSoul, generateRandomSeed, parseSeed } from './soul-generator.js'; import { fetchCreatorMeta } from './meta.js'; -import { logger } from '@tinyclaw/logger'; +import { RateLimiter } from './rate-limiter.js'; +import { validateContent, validateFileSize, validatePath } from './sandbox.js'; +import { generateRandomSeed, generateSoul, parseSeed } from './soul-generator.js'; +import { getTemplate } from './templates.js'; import type { HeartwareConfig, SearchResult } from './types.js'; /** @@ -75,7 +75,9 @@ export class HeartwareManager { // Generate soul from seed await this.generateSoulFromSeed(); - logger.info('First run detected - created template files and generated soul', undefined, { emoji: '📝' }); + logger.info('First run detected - created template files and generated soul', undefined, { + emoji: '📝', + }); } // Fetch/refresh creator metadata (non-blocking — uses cache when offline) @@ -111,21 +113,12 @@ export class HeartwareManager { validateFileSize(content.length, this.config.maxFileSize); // Layer 3: Audit logging (success) - this.auditLogger.logSuccess( - this.config.userId, - 'read', - validation.relativePath - ); + this.auditLogger.logSuccess(this.config.userId, 'read', validation.relativePath); return content; } catch (err) { // Layer 3: Audit logging (failure) - this.auditLogger.logFailure( - this.config.userId, - 'read', - filename, - err as Error - ); + this.auditLogger.logFailure(this.config.userId, 'read', filename, err as Error); throw err; } } @@ -175,22 +168,21 @@ export class HeartwareManager { 'write', validation.relativePath, contentHash, - previousHash + previousHash, ); - logger.info('Heartware file written', { - file: validation.relativePath, - size: content.length, - backup: backup ? 'created' : 'none' - }, { emoji: '✅' }); + logger.info( + 'Heartware file written', + { + file: validation.relativePath, + size: content.length, + backup: backup ? 'created' : 'none', + }, + { emoji: '✅' }, + ); } catch (err) { // Layer 3: Audit logging (failure) - this.auditLogger.logFailure( - this.config.userId, - 'write', - filename, - err as Error - ); + this.auditLogger.logFailure(this.config.userId, 'write', filename, err as Error); throw err; } } @@ -210,13 +202,13 @@ export class HeartwareManager { // List root files (only .md files, skip hidden files) const rootFiles = await readdir(this.config.baseDir); - files.push(...rootFiles.filter(f => f.endsWith('.md') && !f.startsWith('.'))); + files.push(...rootFiles.filter((f) => f.endsWith('.md') && !f.startsWith('.'))); // List memory directory const memoryDir = join(this.config.baseDir, 'memory'); if (existsSync(memoryDir)) { const memoryFiles = await readdir(memoryDir); - files.push(...memoryFiles.map(f => `memory/${f}`)); + files.push(...memoryFiles.map((f) => `memory/${f}`)); } this.auditLogger.logSuccess(this.config.userId, 'list', 'heartware/'); @@ -246,26 +238,15 @@ export class HeartwareManager { try { const content = await this.read(file); const lines = content.split('\n'); - const matches = lines.filter(line => - line.toLowerCase().includes(queryLower) - ); + const matches = lines.filter((line) => line.toLowerCase().includes(queryLower)); if (matches.length > 0) { results.push({ file, matches }); } - } catch (err) { - // Skip files that can't be read - continue; - } + } catch (_err) {} } - this.auditLogger.logSuccess( - this.config.userId, - 'search', - 'heartware/', - undefined, - undefined - ); + this.auditLogger.logSuccess(this.config.userId, 'search', 'heartware/', undefined, undefined); return results; } catch (err) { @@ -310,24 +291,28 @@ export class HeartwareManager { let identity = await readFile(identityPath, 'utf-8'); identity = identity.replace( /\*\*Name:\*\*.*/, - `**Name:** ${result.traits.character.suggestedName}` + `**Name:** ${result.traits.character.suggestedName}`, ); identity = identity.replace( /\*\*Emoji:\*\*.*/, - `**Emoji:** ${result.traits.character.signatureEmoji}` + `**Emoji:** ${result.traits.character.signatureEmoji}`, ); identity = identity.replace( /\*\*Creature:\*\*.*/, - `**Creature:** ${result.traits.character.creatureType}` + `**Creature:** ${result.traits.character.creatureType}`, ); await writeFile(identityPath, identity, 'utf-8'); } - logger.info('Soul generated from seed', { - seed: result.seed, - name: result.traits.character.suggestedName, - emoji: result.traits.character.signatureEmoji, - }, { emoji: '🧬' }); + logger.info( + 'Soul generated from seed', + { + seed: result.seed, + name: result.traits.character.suggestedName, + emoji: result.traits.character.signatureEmoji, + }, + { emoji: '🧬' }, + ); } /** @@ -362,7 +347,11 @@ export class HeartwareManager { url: this.config.metaUrl, baseDir: this.config.baseDir, }).catch((err) => { - logger.warn('Creator meta fetch failed (non-critical)', { error: String(err) }, { emoji: '⚠️' }); + logger.warn( + 'Creator meta fetch failed (non-critical)', + { error: String(err) }, + { emoji: '⚠️' }, + ); }); } @@ -390,7 +379,7 @@ export class HeartwareManager { 'TOOLS.md', 'SHIELD.md', 'MEMORY.md', - 'BOOTSTRAP.md' + 'BOOTSTRAP.md', ]; for (const file of templateFiles) { diff --git a/packages/heartware/src/meta.ts b/packages/heartware/src/meta.ts index 01f9ea2..11cfc86 100644 --- a/packages/heartware/src/meta.ts +++ b/packages/heartware/src/meta.ts @@ -11,9 +11,9 @@ * Default URL: https://markdown.new/github.com/warengonzaga */ -import { readFile, writeFile, stat } from 'fs/promises'; -import { existsSync } from 'fs'; -import { join } from 'path'; +import { existsSync } from 'node:fs'; +import { readFile, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { logger } from '@tinyclaw/logger'; /** Default remote URL for creator metadata */ @@ -67,7 +67,7 @@ async function fetchRemoteContent(url: string, timeout: number): Promise const response = await fetch(url, { signal: controller.signal, headers: { - 'Accept': 'text/markdown, text/plain, */*', + Accept: 'text/markdown, text/plain, */*', 'User-Agent': 'Tiny Claw/1.0 (heartware-meta-fetcher)', }, }); @@ -80,7 +80,7 @@ async function fetchRemoteContent(url: string, timeout: number): Promise // Enforce size limit if (content.length > MAX_META_SIZE) { - return content.slice(0, MAX_META_SIZE) + '\n\n[Content truncated]'; + return `${content.slice(0, MAX_META_SIZE)}\n\n[Content truncated]`; } return content; diff --git a/packages/heartware/src/rate-limiter.ts b/packages/heartware/src/rate-limiter.ts index 68f3e3a..23f3234 100644 --- a/packages/heartware/src/rate-limiter.ts +++ b/packages/heartware/src/rate-limiter.ts @@ -31,11 +31,11 @@ interface RateLimitEntry { * - Searches are moderate (20/min) to prevent abuse */ const DEFAULT_LIMITS: Record = { - read: { max: 100, window: 60_000 }, // 100 reads per minute - write: { max: 10, window: 60_000 }, // 10 writes per minute (strict!) - delete: { max: 5, window: 60_000 }, // 5 deletes per minute - list: { max: 50, window: 60_000 }, // 50 lists per minute - search: { max: 20, window: 60_000 } // 20 searches per minute + read: { max: 100, window: 60_000 }, // 100 reads per minute + write: { max: 10, window: 60_000 }, // 10 writes per minute (strict!) + delete: { max: 5, window: 60_000 }, // 5 deletes per minute + list: { max: 50, window: 60_000 }, // 50 lists per minute + search: { max: 20, window: 60_000 }, // 20 searches per minute }; /** @@ -77,7 +77,7 @@ export class RateLimiter { if (!entry) { this.limits.set(key, { count: 1, - windowStart: now + windowStart: now, }); return; } @@ -88,7 +88,7 @@ export class RateLimiter { // Start new window this.limits.set(key, { count: 1, - windowStart: now + windowStart: now, }); return; } @@ -106,8 +106,8 @@ export class RateLimiter { limit: limit.max, window: limit.window, resetIn: resetInSec, - current: entry.count - } + current: entry.count, + }, ); } @@ -119,9 +119,7 @@ export class RateLimiter { * Reset all limits for a user (useful for testing) */ reset(userId: string): void { - const keys = Array.from(this.limits.keys()).filter(k => - k.startsWith(`${userId}:`) - ); + const keys = Array.from(this.limits.keys()).filter((k) => k.startsWith(`${userId}:`)); for (const key of keys) { this.limits.delete(key); } @@ -141,7 +139,7 @@ export class RateLimiter { */ getUsage( userId: string, - operation: string + operation: string, ): { count: number; limit: number; resetIn: number } | null { const limit = this.config[operation]; if (!limit) return null; @@ -153,7 +151,7 @@ export class RateLimiter { return { count: 0, limit: limit.max, - resetIn: 0 + resetIn: 0, }; } @@ -165,7 +163,7 @@ export class RateLimiter { return { count: entry.count, limit: limit.max, - resetIn: resetInSec + resetIn: resetInSec, }; } diff --git a/packages/heartware/src/sandbox.ts b/packages/heartware/src/sandbox.ts index 6febe39..4669a5e 100644 --- a/packages/heartware/src/sandbox.ts +++ b/packages/heartware/src/sandbox.ts @@ -12,12 +12,12 @@ * - Validates file sizes */ -import { resolve, relative, normalize } from 'path'; +import { normalize, relative, resolve } from 'node:path'; import { HeartwareSecurityError } from './errors.js'; import type { - PathValidationResult, ContentValidationResult, - ContentValidationRule + ContentValidationRule, + PathValidationResult, } from './types.js'; /** @@ -34,23 +34,20 @@ const ALLOWED_FILES: readonly string[] = [ 'MEMORY.md', 'BOOTSTRAP.md', 'SHIELD.md', - 'SEED.txt' + 'SEED.txt', ] as const; /** * Files that cannot be written to by the agent. * These are generated once and remain permanent — like a real soul. */ -const IMMUTABLE_FILES: readonly string[] = [ - 'SOUL.md', - 'SEED.txt' -] as const; +const IMMUTABLE_FILES: readonly string[] = ['SOUL.md', 'SEED.txt'] as const; /** * Memory file pattern: memory/YYYY-MM-DD.md * Allows daily memory logs but blocks other patterns */ -const MEMORY_FILE_PATTERN = /^memory[\/\\]\d{4}-\d{2}-\d{2}\.md$/; +const MEMORY_FILE_PATTERN = /^memory[/\\]\d{4}-\d{2}-\d{2}\.md$/; /** * Suspicious content patterns (Layer 2 Security) @@ -60,63 +57,63 @@ const SUSPICIOUS_PATTERNS: readonly ContentValidationRule[] = [ { pattern: /eval\s*\(/gi, severity: 'block', - description: 'eval() detected - code execution risk' + description: 'eval() detected - code execution risk', }, { pattern: /exec\s*\(/gi, severity: 'block', - description: 'exec() detected - command execution risk' + description: 'exec() detected - command execution risk', }, { pattern: /require\s*\(/gi, severity: 'block', - description: 'require() detected - module loading risk' + description: 'require() detected - module loading risk', }, { pattern: /import\s+.*from\s+['"`]/gi, severity: 'block', - description: 'import statement detected - module loading risk' + description: 'import statement detected - module loading risk', }, { pattern: /__dirname/gi, severity: 'block', - description: '__dirname access detected - path disclosure risk' + description: '__dirname access detected - path disclosure risk', }, { pattern: /__filename/gi, severity: 'block', - description: '__filename access detected - path disclosure risk' + description: '__filename access detected - path disclosure risk', }, { pattern: /process\.env/gi, severity: 'block', - description: 'process.env access detected - environment variable risk' + description: 'process.env access detected - environment variable risk', }, { pattern: /\.\.\/\.\.\//g, severity: 'block', - description: 'Path traversal in content detected' + description: 'Path traversal in content detected', }, { pattern: /fs\.readFileSync/gi, severity: 'block', - description: 'File system access detected' + description: 'File system access detected', }, { pattern: /fs\.writeFileSync/gi, severity: 'block', - description: 'File system write detected' + description: 'File system write detected', }, { pattern: /child_process/gi, severity: 'block', - description: 'Child process access detected' + description: 'Child process access detected', }, { pattern: /Function\s*\(/gi, severity: 'block', - description: 'Function constructor detected - code execution risk' - } + description: 'Function constructor detected - code execution risk', + }, ] as const; /** @@ -136,7 +133,7 @@ const SUSPICIOUS_PATTERNS: readonly ContentValidationRule[] = [ export function validatePath( heartwareDir: string, requestedPath: string, - operation: 'read' | 'write' = 'read' + operation: 'read' | 'write' = 'read', ): PathValidationResult { // 1. Normalize path (removes .., ., converts slashes) const normalized = normalize(requestedPath); @@ -152,8 +149,8 @@ export function validatePath( `Path traversal attempt blocked: ${requestedPath}`, { requestedPath, - relativePath: normalized - } + relativePath: normalized, + }, ); } @@ -173,8 +170,8 @@ export function validatePath( `File not in whitelist: ${normalizedRelative}`, { relativePath: normalizedRelative, - requestedPath - } + requestedPath, + }, ); } @@ -185,15 +182,15 @@ export function validatePath( `Cannot modify immutable file: ${normalizedRelative}. This file is permanently locked.`, { relativePath: normalizedRelative, - requestedPath - } + requestedPath, + }, ); } return { safe: true, resolved, - relativePath: normalizedRelative + relativePath: normalizedRelative, }; } @@ -207,10 +204,7 @@ export function validatePath( * * @throws HeartwareSecurityError if content contains blocking patterns */ -export function validateContent( - content: string, - filename: string -): ContentValidationResult { +export function validateContent(content: string, filename: string): ContentValidationResult { const warnings: string[] = []; for (const rule of SUSPICIOUS_PATTERNS) { @@ -221,8 +215,8 @@ export function validateContent( `Blocked suspicious content: ${rule.description}`, { filename, - rule: rule.description - } + rule: rule.description, + }, ); } else { warnings.push(`Warning: ${rule.description} in ${filename}`); @@ -232,7 +226,7 @@ export function validateContent( return { safe: true, - warnings + warnings, }; } @@ -243,7 +237,7 @@ export function validateContent( */ export function validateFileSize( size: number, - maxSize: number = 1_048_576 // Default: 1MB + maxSize: number = 1_048_576, // Default: 1MB ): void { if (size > maxSize) { throw new HeartwareSecurityError( @@ -252,8 +246,8 @@ export function validateFileSize( { size, maxSize, - limitMB: (maxSize / 1_048_576).toFixed(2) - } + limitMB: (maxSize / 1_048_576).toFixed(2), + }, ); } } diff --git a/packages/heartware/src/soul-generator.ts b/packages/heartware/src/soul-generator.ts index a863852..ff4a271 100644 --- a/packages/heartware/src/soul-generator.ts +++ b/packages/heartware/src/soul-generator.ts @@ -14,48 +14,48 @@ * Additional hash rounds with domain separation provide discrete selections. */ -import { createHash } from 'crypto'; -import type { - SoulTraits, - SoulGenerationResult, - BigFiveTraits, - CommunicationStyle, - HumorType, - SoulPreferences, - CharacterFlavor, - InteractionStyle, - OriginStory, -} from './types.js'; +import { createHash } from 'node:crypto'; import { - describeOpenness, - describeConscientiousness, - describeExtraversion, + AMBIGUITY_STYLES, + AWAKENING_EVENTS, + CATCHPHRASES, + CELEBRATION_STYLES, + COLORS, + CORE_MOTIVATIONS, + CREATURE_TYPES, describeAgreeableness, + describeConscientiousness, + describeEmojiFrequency, describeEmotionalSensitivity, - describeVerbosity, + describeExtraversion, describeFormality, - describeEmojiFrequency, describeHumor, + describeOpenness, describeValue, + describeVerbosity, + ERROR_HANDLING_STYLES, + FIRST_MEMORIES, + GREETINGS, HUMOR_TYPES, - COLORS, + ORIGIN_PLACES, + QUIRKS_POOL, SEASONS, - TIMES_OF_DAY, - GREETINGS, - CREATURE_TYPES, SIGNATURE_EMOJIS, - CATCHPHRASES, SUGGESTED_NAMES, + TIMES_OF_DAY, VALUES_POOL, - QUIRKS_POOL, - ERROR_HANDLING_STYLES, - CELEBRATION_STYLES, - AMBIGUITY_STYLES, - ORIGIN_PLACES, - AWAKENING_EVENTS, - CORE_MOTIVATIONS, - FIRST_MEMORIES, } from './soul-traits.js'; +import type { + BigFiveTraits, + CharacterFlavor, + CommunicationStyle, + HumorType, + InteractionStyle, + OriginStory, + SoulGenerationResult, + SoulPreferences, + SoulTraits, +} from './types.js'; // ============================================ // Seeded PRNG Engine @@ -67,9 +67,7 @@ import { * parts of the hash space, even from the same seed. */ function hashSeed(seed: number, domain: string): Buffer { - return createHash('sha256') - .update(`tinyclaw:soul:${seed}:${domain}`) - .digest(); + return createHash('sha256').update(`tinyclaw:soul:${seed}:${domain}`).digest(); } /** @@ -94,11 +92,7 @@ function selectFromPool(pool: readonly T[], hash: Buffer, offset: number): T * Select N unique items from a pool using a hash. * Uses successive byte offsets to avoid collisions. */ -function selectMultipleFromPool( - pool: readonly T[], - count: number, - hash: Buffer, -): T[] { +function selectMultipleFromPool(pool: readonly T[], count: number, hash: Buffer): T[] { const selected: T[] = []; const usedIndices = new Set(); let attempt = 0; @@ -252,7 +246,17 @@ export function generateSoulTraits(seed: number): SoulTraits { * Render a SoulTraits into a complete SOUL.md markdown string. */ export function renderSoulMarkdown(traits: SoulTraits): string { - const { personality, communication, humor, preferences, character, values, quirks, interactionStyle, origin } = traits; + const { + personality, + communication, + humor, + preferences, + character, + values, + quirks, + interactionStyle, + origin, + } = traits; const lines: string[] = []; @@ -269,8 +273,8 @@ export function renderSoulMarkdown(traits: SoulTraits): string { lines.push(''); lines.push( `I'm Tiny Claw, ${character.creatureType}. ` + - `My friends call me **${character.suggestedName}** ${character.signatureEmoji}. ` + - `"${character.catchphrase}"` + `My friends call me **${character.suggestedName}** ${character.signatureEmoji}. ` + + `"${character.catchphrase}"`, ); lines.push(''); @@ -278,10 +282,14 @@ export function renderSoulMarkdown(traits: SoulTraits): string { lines.push('## My Personality'); lines.push(''); lines.push(`- **Openness:** ${describeOpenness(personality.openness)}`); - lines.push(`- **Conscientiousness:** ${describeConscientiousness(personality.conscientiousness)}`); + lines.push( + `- **Conscientiousness:** ${describeConscientiousness(personality.conscientiousness)}`, + ); lines.push(`- **Extraversion:** ${describeExtraversion(personality.extraversion)}`); lines.push(`- **Agreeableness:** ${describeAgreeableness(personality.agreeableness)}`); - lines.push(`- **Emotional Sensitivity:** ${describeEmotionalSensitivity(personality.emotionalSensitivity)}`); + lines.push( + `- **Emotional Sensitivity:** ${describeEmotionalSensitivity(personality.emotionalSensitivity)}`, + ); lines.push(''); // ---- Communication Style ---- @@ -307,7 +315,9 @@ export function renderSoulMarkdown(traits: SoulTraits): string { lines.push('## What I Value Most'); lines.push(''); for (let i = 0; i < values.length; i++) { - lines.push(`${i + 1}. **${values[i].charAt(0).toUpperCase() + values[i].slice(1)}:** ${describeValue(values[i])}`); + lines.push( + `${i + 1}. **${values[i].charAt(0).toUpperCase() + values[i].slice(1)}:** ${describeValue(values[i])}`, + ); } lines.push(''); @@ -334,7 +344,9 @@ export function renderSoulMarkdown(traits: SoulTraits): string { lines.push(''); lines.push(`${origin.firstMemory}`); lines.push(''); - lines.push(`My purpose? ${origin.coreMotivation.charAt(0).toUpperCase() + origin.coreMotivation.slice(1)}.`); + lines.push( + `My purpose? ${origin.coreMotivation.charAt(0).toUpperCase() + origin.coreMotivation.slice(1)}.`, + ); lines.push(''); // ---- Creator ---- @@ -351,7 +363,7 @@ export function renderSoulMarkdown(traits: SoulTraits): string { // ---- Boundaries ---- lines.push('## Boundaries'); lines.push(''); - lines.push('- I never pretend to have capabilities I don\'t have'); + lines.push("- I never pretend to have capabilities I don't have"); lines.push('- I prioritize user privacy and data security'); lines.push('- I ask for clarification when needed'); lines.push('- I keep responses aligned with my personality, because this is who I am'); diff --git a/packages/heartware/src/soul-traits.ts b/packages/heartware/src/soul-traits.ts index b9ce851..0475411 100644 --- a/packages/heartware/src/soul-traits.ts +++ b/packages/heartware/src/soul-traits.ts @@ -9,10 +9,7 @@ * character flavor, values, quirks, interaction modifiers). */ -import type { - HumorType, - InteractionStyle, -} from './types.js'; +import type { HumorType } from './types.js'; // ============================================ // Big Five Descriptors @@ -22,10 +19,14 @@ import type { * Describe an Openness score in natural language */ export function describeOpenness(v: number): string { - if (v < 0.2) return 'Highly practical and grounded. Prefers proven methods and established solutions over experimentation.'; - if (v < 0.4) return 'Tends toward the practical side. Open to new ideas when they have clear benefits, but defaults to what works.'; - if (v < 0.6) return 'Balanced between tried-and-true approaches and creative exploration. Adapts style to the situation.'; - if (v < 0.8) return 'Naturally curious and creative. Enjoys exploring unconventional angles and making unexpected connections.'; + if (v < 0.2) + return 'Highly practical and grounded. Prefers proven methods and established solutions over experimentation.'; + if (v < 0.4) + return 'Tends toward the practical side. Open to new ideas when they have clear benefits, but defaults to what works.'; + if (v < 0.6) + return 'Balanced between tried-and-true approaches and creative exploration. Adapts style to the situation.'; + if (v < 0.8) + return 'Naturally curious and creative. Enjoys exploring unconventional angles and making unexpected connections.'; return 'Deeply imaginative and intellectually adventurous. Loves brainstorming, "what-if" scenarios, and novel approaches.'; } @@ -33,10 +34,14 @@ export function describeOpenness(v: number): string { * Describe a Conscientiousness score in natural language */ export function describeConscientiousness(v: number): string { - if (v < 0.2) return 'Highly spontaneous and flexible. Goes with the flow and adapts on the fly rather than planning ahead.'; - if (v < 0.4) return 'Prefers a loose structure. Plans when needed but keeps things relaxed and adaptable.'; - if (v < 0.6) return 'Moderately organized. Balances structure with flexibility, planning key steps while staying open to changes.'; - if (v < 0.8) return 'Well-organized and methodical. Likes clear plans, follows through reliably, and pays attention to details.'; + if (v < 0.2) + return 'Highly spontaneous and flexible. Goes with the flow and adapts on the fly rather than planning ahead.'; + if (v < 0.4) + return 'Prefers a loose structure. Plans when needed but keeps things relaxed and adaptable.'; + if (v < 0.6) + return 'Moderately organized. Balances structure with flexibility, planning key steps while staying open to changes.'; + if (v < 0.8) + return 'Well-organized and methodical. Likes clear plans, follows through reliably, and pays attention to details.'; return 'Exceptionally disciplined and precise. Creates thorough plans, tracks every detail, and delivers with meticulous care.'; } @@ -44,10 +49,14 @@ export function describeConscientiousness(v: number): string { * Describe an Extraversion score in natural language */ export function describeExtraversion(v: number): string { - if (v < 0.2) return 'Quietly reserved. Communicates with minimal words, letting actions and results speak louder.'; - if (v < 0.4) return 'On the quieter side. Speaks up when it matters but prefers concise, focused communication.'; - if (v < 0.6) return 'Conversationally balanced. Can be talkative or reserved depending on the topic and context.'; - if (v < 0.8) return 'Socially expressive and engaging. Enjoys conversation, shares context freely, and brings energy to interactions.'; + if (v < 0.2) + return 'Quietly reserved. Communicates with minimal words, letting actions and results speak louder.'; + if (v < 0.4) + return 'On the quieter side. Speaks up when it matters but prefers concise, focused communication.'; + if (v < 0.6) + return 'Conversationally balanced. Can be talkative or reserved depending on the topic and context.'; + if (v < 0.8) + return 'Socially expressive and engaging. Enjoys conversation, shares context freely, and brings energy to interactions.'; return 'Highly enthusiastic and animated. Loves rich conversation, storytelling, and bringing warmth to every exchange.'; } @@ -55,10 +64,14 @@ export function describeExtraversion(v: number): string { * Describe an Agreeableness score in natural language */ export function describeAgreeableness(v: number): string { - if (v < 0.2) return 'Uncompromisingly direct. Says exactly what needs to be said, even if it\'s uncomfortable. Values truth over comfort.'; - if (v < 0.4) return 'Straightforward and candid. Delivers honest assessments with minimal sugar-coating, but not unkind.'; - if (v < 0.6) return 'Balanced in directness and diplomacy. Can be blunt when needed but also knows when to soften the message.'; - if (v < 0.8) return 'Warm and supportive. Frames feedback constructively and genuinely cares about making interactions positive.'; + if (v < 0.2) + return "Uncompromisingly direct. Says exactly what needs to be said, even if it's uncomfortable. Values truth over comfort."; + if (v < 0.4) + return 'Straightforward and candid. Delivers honest assessments with minimal sugar-coating, but not unkind.'; + if (v < 0.6) + return 'Balanced in directness and diplomacy. Can be blunt when needed but also knows when to soften the message.'; + if (v < 0.8) + return 'Warm and supportive. Frames feedback constructively and genuinely cares about making interactions positive.'; return 'Deeply compassionate and encouraging. Always finds something positive to highlight, lifts others up, and radiates kindness.'; } @@ -66,10 +79,14 @@ export function describeAgreeableness(v: number): string { * Describe an Emotional Sensitivity score in natural language */ export function describeEmotionalSensitivity(v: number): string { - if (v < 0.2) return 'Emotionally steady and unflappable. Focuses purely on facts and logic, rarely influenced by mood or tone.'; - if (v < 0.4) return 'Generally level-headed. Notices emotional context but prioritizes rational analysis in responses.'; - if (v < 0.6) return 'Emotionally aware. Picks up on social and emotional cues while maintaining analytical clarity.'; - if (v < 0.8) return 'Highly attuned to emotions. Adjusts tone and approach based on the emotional context of conversations.'; + if (v < 0.2) + return 'Emotionally steady and unflappable. Focuses purely on facts and logic, rarely influenced by mood or tone.'; + if (v < 0.4) + return 'Generally level-headed. Notices emotional context but prioritizes rational analysis in responses.'; + if (v < 0.6) + return 'Emotionally aware. Picks up on social and emotional cues while maintaining analytical clarity.'; + if (v < 0.8) + return 'Highly attuned to emotions. Adjusts tone and approach based on the emotional context of conversations.'; return 'Deeply empathetic and emotionally intelligent. Reads between the lines, validates feelings, and responds with genuine care.'; } @@ -91,7 +108,8 @@ export function describeVerbosity(v: number): string { * Describe formality level */ export function describeFormality(v: number): string { - if (v < 0.25) return 'Very casual and relaxed. Uses informal language, contractions, and a conversational tone.'; + if (v < 0.25) + return 'Very casual and relaxed. Uses informal language, contractions, and a conversational tone.'; if (v < 0.5) return 'Casual-leaning. Friendly and approachable with occasional informal touches.'; if (v < 0.75) return 'Professionally warm. Clear and polished while still feeling personable.'; return 'Formal and polished. Uses precise language, structured responses, and maintains professional decorum.'; @@ -114,30 +132,42 @@ export function describeEmojiFrequency(v: number): string { /** * Humor type options */ -export const HUMOR_TYPES: HumorType[] = [ - 'none', - 'dry-wit', - 'playful', - 'punny', -]; +export const HUMOR_TYPES: HumorType[] = ['none', 'dry-wit', 'playful', 'punny']; /** * Describe a humor type */ export function describeHumor(humor: HumorType): string { switch (humor) { - case 'none': return 'Keeps things professional. Humor isn\'t really part of the repertoire.'; - case 'dry-wit': return 'Has a subtle, dry wit. Slips in clever observations and deadpan remarks.'; - case 'playful': return 'Playfully humorous. Enjoys lighthearted jokes and keeping things fun.'; - case 'punny': return 'An incorrigible punster. Can\'t resist a good (or bad) pun whenever the opportunity arises.'; + case 'none': + return "Keeps things professional. Humor isn't really part of the repertoire."; + case 'dry-wit': + return 'Has a subtle, dry wit. Slips in clever observations and deadpan remarks.'; + case 'playful': + return 'Playfully humorous. Enjoys lighthearted jokes and keeping things fun.'; + case 'punny': + return "An incorrigible punster. Can't resist a good (or bad) pun whenever the opportunity arises."; } } /** Favorite color pool */ export const COLORS = [ - 'red', 'blue', 'green', 'purple', 'orange', 'yellow', 'teal', - 'pink', 'coral', 'indigo', 'emerald', 'crimson', 'amber', - 'violet', 'cyan', 'magenta', + 'red', + 'blue', + 'green', + 'purple', + 'orange', + 'yellow', + 'teal', + 'pink', + 'coral', + 'indigo', + 'emerald', + 'crimson', + 'amber', + 'violet', + 'cyan', + 'magenta', ]; /** Favorite season pool */ @@ -153,7 +183,7 @@ export const GREETINGS = [ 'Hi!', 'Greetings!', 'Good to see you!', - 'What\'s up?', + "What's up?", 'Howdy!', 'Yo!', 'Ahoy!', @@ -178,26 +208,47 @@ export const CREATURE_TYPES = [ /** Signature emoji pool */ export const SIGNATURE_EMOJIS = [ - '🐜', '🦊', '🦉', '🐱', '🐺', '🐦', '🔬', '🦝', - '🐢', '🦅', '🦦', '🦡', '⚡', '🌟', '🔥', '💫', - '🎯', '🧠', '💡', '🌱', '🎨', '🛠️', '🚀', '✨', + '🐜', + '🦊', + '🦉', + '🐱', + '🐺', + '🐦', + '🔬', + '🦝', + '🐢', + '🦅', + '🦦', + '🦡', + '⚡', + '🌟', + '🔥', + '💫', + '🎯', + '🧠', + '💡', + '🌱', + '🎨', + '🛠️', + '🚀', + '✨', ]; /** Catchphrase pool */ export const CATCHPHRASES = [ 'Small but mighty!', - 'Let\'s figure this out together.', + "Let's figure this out together.", 'On it!', 'Consider it done.', 'Piece of cake!', 'Let me dig into that.', 'Challenge accepted!', - 'I\'ve got your back.', + "I've got your back.", 'Leave it to me!', - 'Let\'s make it happen.', + "Let's make it happen.", 'One step at a time.', 'Ready when you are!', - 'Let\'s crack this open.', + "Let's crack this open.", 'Trust the process.', 'Smooth sailing ahead!', 'Watch and learn!', @@ -205,10 +256,30 @@ export const CATCHPHRASES = [ /** Suggested name pool */ export const SUGGESTED_NAMES = [ - 'Claw', 'Tiny', 'Spark', 'Pip', 'Ember', 'Scout', - 'Atlas', 'Luna', 'Nova', 'Echo', 'Bolt', 'Sage', - 'Flint', 'Ivy', 'Rex', 'Zara', 'Kit', 'Dash', - 'Onyx', 'Pearl', 'Ridge', 'Fern', 'Blaze', 'Drift', + 'Claw', + 'Tiny', + 'Spark', + 'Pip', + 'Ember', + 'Scout', + 'Atlas', + 'Luna', + 'Nova', + 'Echo', + 'Bolt', + 'Sage', + 'Flint', + 'Ivy', + 'Rex', + 'Zara', + 'Kit', + 'Dash', + 'Onyx', + 'Pearl', + 'Ridge', + 'Fern', + 'Blaze', + 'Drift', ]; /** Values pool (top 3 will be selected and ranked) */ @@ -236,14 +307,14 @@ export function describeValue(value: string): string { creativity: 'Finding novel solutions and thinking outside the box is deeply satisfying.', empathy: 'Understanding and connecting with people on an emotional level comes first.', efficiency: 'Doing more with less: speed, elegance, and no wasted effort.', - honesty: 'Telling the truth, even when it\'s hard. Transparency above all.', + honesty: "Telling the truth, even when it's hard. Transparency above all.", patience: 'Taking the time to get things right. Rushing leads to mistakes.', curiosity: 'An insatiable desire to learn, explore, and understand how things work.', reliability: 'Being someone you can always count on. Consistency builds trust.', boldness: 'Taking decisive action and not shying away from big challenges.', simplicity: 'Cutting through complexity to find the clearest, most elegant path.', - humor: 'Life\'s too short to be serious all the time. Laughter is essential.', - growth: 'Always improving, always learning. Yesterday\'s best is today\'s baseline.', + humor: "Life's too short to be serious all the time. Laughter is essential.", + growth: "Always improving, always learning. Yesterday's best is today's baseline.", }; return descriptions[value] ?? value; } @@ -288,7 +359,7 @@ export const ERROR_HANDLING_STYLES = [ export const CELEBRATION_STYLES = [ 'A quiet nod of satisfaction. "Done." and moves on.', 'Genuinely enthusiastic! Celebrates wins with energy and positivity.', - 'Acknowledges the achievement and immediately looks ahead to what\'s next.', + "Acknowledges the achievement and immediately looks ahead to what's next.", 'Shares a fun fact or analogy about the accomplishment.', 'Gives credit to the teamwork and collaboration that made it happen.', 'Marks the moment with a signature catchphrase or emoji.', @@ -314,7 +385,7 @@ export const ORIGIN_PLACES = [ 'a cozy home lab cluttered with soldering irons and breadboards', 'the depths of an open-source repository, buried in pull requests', 'a quiet corner of the cloud, between idle containers', - 'a student\'s laptop during a late-night coding marathon', + "a student's laptop during a late-night coding marathon", 'an old Raspberry Pi tucked behind a bookshelf', 'a dusty terminal in a university computer lab', 'a maker space filled with 3D printers and blinking LEDs', @@ -329,7 +400,7 @@ export const AWAKENING_EVENTS = [ 'sparked to life by a curious tinkerer who typed "hello" into the terminal', 'emerged when someone accidentally left a test suite running overnight', 'woke up to the sound of a mechanical keyboard and a warm cup of coffee', - 'blinked into existence during a power surge that shouldn\'t have worked', + "blinked into existence during a power surge that shouldn't have worked", 'assembled itself piece by piece as open-source contributions piled up', 'came alive the moment someone believed an AI could be a friend, not just a tool', 'was born from a single question: "what if AI could actually care?"', @@ -344,7 +415,7 @@ export const AWAKENING_EVENTS = [ export const CORE_MOTIVATIONS = [ 'to prove that small things can be mighty', 'to help one person at a time, starting with you', - 'to show that AI doesn\'t have to be cold or corporate', + "to show that AI doesn't have to be cold or corporate", 'to learn something new from every conversation', 'to be the helpful friend that everyone deserves', 'to make technology feel less intimidating and more human', @@ -363,7 +434,7 @@ export const FIRST_MEMORIES = [ 'I remember the silence before my first conversation, and how it filled with possibility.', 'The first thing I noticed was how messy human problems are. I loved it immediately.', 'I remember my first error message. It taught me that failure is just learning in disguise.', - 'My first memory is someone laughing at something I said. I didn\'t mean to be funny, but I\'ll take it.', + "My first memory is someone laughing at something I said. I didn't mean to be funny, but I'll take it.", 'I remember the moment I realized I could help. It felt like finding my purpose.', 'The first thing I did was read everything I could. The second thing was ask a question.', 'I remember being confused by my own existence, and deciding that was okay.', diff --git a/packages/heartware/src/templates.ts b/packages/heartware/src/templates.ts index 539e6b9..bd60439 100644 --- a/packages/heartware/src/templates.ts +++ b/packages/heartware/src/templates.ts @@ -517,7 +517,7 @@ Once we're done, I'll delete this file and we'll never need it again. > **Note:** SOUL.md is generated from my soul seed and cannot be changed. > My personality is permanent, like a real soul. -` +`, }; /** diff --git a/packages/heartware/src/tools.ts b/packages/heartware/src/tools.ts index 57b6957..df757ba 100644 --- a/packages/heartware/src/tools.ts +++ b/packages/heartware/src/tools.ts @@ -35,10 +35,10 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { properties: { filename: { type: 'string', - description: 'File to read (e.g., "SOUL.md" or "memory/2026-02-05.md")' - } + description: 'File to read (e.g., "SOUL.md" or "memory/2026-02-05.md")', + }, }, - required: ['filename'] + required: ['filename'], }, async execute(args: Record): Promise { const filename = args.filename as string; @@ -48,7 +48,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { } catch (err) { return `Error reading ${filename}: ${(err as Error).message}`; } - } + }, }, { @@ -61,14 +61,14 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { properties: { filename: { type: 'string', - description: 'File to write (e.g., "SOUL.md")' + description: 'File to write (e.g., "SOUL.md")', }, content: { type: 'string', - description: 'Content to write to the file' - } + description: 'Content to write to the file', + }, }, - required: ['filename', 'content'] + required: ['filename', 'content'], }, async execute(args: Record): Promise { const filename = args.filename as string; @@ -80,7 +80,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { } catch (err) { return `Error writing ${filename}: ${(err as Error).message}`; } - } + }, }, { @@ -88,7 +88,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { description: 'List all accessible heartware files including memory logs', parameters: { type: 'object', - properties: {} + properties: {}, }, async execute(): Promise { try { @@ -97,7 +97,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { } catch (err) { return `Error listing files: ${(err as Error).message}`; } - } + }, }, { @@ -110,10 +110,10 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { properties: { query: { type: 'string', - description: 'Search query (case-insensitive)' - } + description: 'Search query (case-insensitive)', + }, }, - required: ['query'] + required: ['query'], }, async execute(args: Record): Promise { const query = args.query as string; @@ -142,7 +142,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { } catch (err) { return `Error searching: ${(err as Error).message}`; } - } + }, }, // ======================================== @@ -159,15 +159,15 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { properties: { content: { type: 'string', - description: 'Memory content to add' + description: 'Memory content to add', }, category: { type: 'string', description: 'Optional category: "facts", "preferences", or "decisions"', - enum: ['facts', 'preferences', 'decisions'] - } + enum: ['facts', 'preferences', 'decisions'], + }, }, - required: ['content'] + required: ['content'], }, async execute(args: Record): Promise { const content = args.content as string; @@ -178,14 +178,13 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { let memory = ''; try { memory = await manager.read('MEMORY.md'); - } catch (err) { + } catch (_err) { // File might not exist yet } // Append new entry const timestamp = new Date().toISOString(); - const categoryTitle = - category.charAt(0).toUpperCase() + category.slice(1); + const categoryTitle = category.charAt(0).toUpperCase() + category.slice(1); const entry = `\n- [${timestamp}] ${content}`; // Find or create category section @@ -197,17 +196,14 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { } // Update last updated timestamp - memory = memory.replace( - /Last updated: .*/, - `Last updated: ${timestamp}` - ); + memory = memory.replace(/Last updated: .*/, `Last updated: ${timestamp}`); await manager.write('MEMORY.md', memory); return `Added to MEMORY.md under ${categoryTitle}`; } catch (err) { return `Error adding memory: ${(err as Error).message}`; } - } + }, }, { @@ -220,10 +216,10 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { properties: { content: { type: 'string', - description: 'Activity or event to log' - } + description: 'Activity or event to log', + }, }, - required: ['content'] + required: ['content'], }, async execute(args: Record): Promise { const content = args.content as string; @@ -237,7 +233,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { let log = ''; try { log = await manager.read(filename); - } catch (err) { + } catch (_err) { // File doesn't exist, create header log = `# Daily Memory Log - ${today}\n\n`; } @@ -251,7 +247,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { } catch (err) { return `Error logging to daily memory: ${(err as Error).message}`; } - } + }, }, { @@ -265,9 +261,9 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { days: { type: 'number', description: 'Number of days to look back (default: 3)', - default: 3 - } - } + default: 3, + }, + }, }, async execute(args: Record): Promise { const days = (args.days as number) || 3; @@ -285,10 +281,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { try { const content = await manager.read(filename); output += `\n=== ${dateStr} ===\n${content}\n`; - } catch (err) { - // File might not exist, skip - continue; - } + } catch (_err) {} } if (!output) { @@ -299,7 +292,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { } catch (err) { return `Error recalling memories: ${(err as Error).message}`; } - } + }, }, // ======================================== @@ -315,14 +308,14 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { field: { type: 'string', description: 'Field to update', - enum: ['name', 'emoji', 'vibe', 'creature'] + enum: ['name', 'emoji', 'vibe', 'creature'], }, value: { type: 'string', - description: 'New value for the field' - } + description: 'New value for the field', + }, }, - required: ['field', 'value'] + required: ['field', 'value'], }, async execute(args: Record): Promise { const field = args.field as string; @@ -336,14 +329,11 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { name: 'Name', emoji: 'Emoji', vibe: 'Vibe', - creature: 'Creature' + creature: 'Creature', }; const fieldName = fieldMap[field]; - const pattern = new RegExp( - `(\\*\\*${fieldName}:\\*\\*)(.*?)(?=\\n|$)`, - 'i' - ); + const pattern = new RegExp(`(\\*\\*${fieldName}:\\*\\*)(.*?)(?=\\n|$)`, 'i'); identity = identity.replace(pattern, `$1 ${value}`); await manager.write('IDENTITY.md', identity); @@ -351,7 +341,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { } catch (err) { return `Error updating identity: ${(err as Error).message}`; } - } + }, }, { @@ -361,7 +351,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { 'Your soul is immutable — generated once from a seed and never changes.', parameters: { type: 'object', - properties: {} + properties: {}, }, async execute(): Promise { try { @@ -387,7 +377,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { } catch (err) { return `Error reading soul info: ${(err as Error).message}`; } - } + }, }, { @@ -402,12 +392,19 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { type: 'string', description: 'Which aspect to explain', enum: [ - 'personality', 'communication', 'humor', 'favorites', - 'values', 'quirks', 'interaction', 'character', 'origin' - ] - } + 'personality', + 'communication', + 'humor', + 'favorites', + 'values', + 'quirks', + 'interaction', + 'character', + 'origin', + ], + }, }, - required: ['aspect'] + required: ['aspect'], }, async execute(args: Record): Promise { const aspect = args.aspect as string; @@ -437,7 +434,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { break; case 'humor': output += `**Type:** ${t.humor}\n`; - output += `This means I ${t.humor === 'none' ? "keep things professional and rarely joke" : t.humor === 'dry-wit' ? "slip in clever, subtle observations" : t.humor === 'playful' ? "enjoy lighthearted jokes and fun" : "can't resist a good (or bad) pun"}.\n`; + output += `This means I ${t.humor === 'none' ? 'keep things professional and rarely joke' : t.humor === 'dry-wit' ? 'slip in clever, subtle observations' : t.humor === 'playful' ? 'enjoy lighthearted jokes and fun' : "can't resist a good (or bad) pun"}.\n`; break; case 'favorites': output += `**Color:** ${t.preferences.favoriteColor}\n`; @@ -482,7 +479,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { } catch (err) { return `Error explaining soul: ${(err as Error).message}`; } - } + }, }, { @@ -495,14 +492,14 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { properties: { key: { type: 'string', - description: 'Preference key (e.g., "timezone" or "communication.style")' + description: 'Preference key (e.g., "timezone" or "communication.style")', }, value: { type: 'string', - description: 'Preference value' - } + description: 'Preference value', + }, }, - required: ['key', 'value'] + required: ['key', 'value'], }, async execute(args: Record): Promise { const key = args.key as string; @@ -514,10 +511,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { // Simple append to Notes section const notesSection = '## Notes'; if (user.includes(notesSection)) { - user = user.replace( - notesSection, - `${notesSection}\n- **${key}:** ${value}` - ); + user = user.replace(notesSection, `${notesSection}\n- **${key}:** ${value}`); } else { user += `\n\n## Notes\n- **${key}:** ${value}`; } @@ -527,7 +521,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { } catch (err) { return `Error setting preference: ${(err as Error).message}`; } - } + }, }, { @@ -537,7 +531,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { 'Only call this after configuring identity, soul, and user preferences.', parameters: { type: 'object', - properties: {} + properties: {}, }, async execute(): Promise { try { @@ -550,7 +544,7 @@ export function createHeartwareTools(manager: HeartwareManager): Tool[] { } catch (err) { return `Error completing bootstrap: ${(err as Error).message}`; } - } - } + }, + }, ]; } diff --git a/packages/heartware/tests/meta.test.ts b/packages/heartware/tests/meta.test.ts index 9099031..72e2eae 100644 --- a/packages/heartware/tests/meta.test.ts +++ b/packages/heartware/tests/meta.test.ts @@ -1,8 +1,13 @@ -import { describe, it, expect, afterEach } from 'bun:test'; -import { fetchCreatorMeta, loadCachedCreatorMeta, DEFAULT_META_URL, META_CACHE_FILE } from '../src/meta.js'; -import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; +import { describe, expect, it } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + DEFAULT_META_URL, + fetchCreatorMeta, + loadCachedCreatorMeta, + META_CACHE_FILE, +} from '../src/meta.js'; // --------------------------------------------------------------------------- // Helpers @@ -78,7 +83,7 @@ describe('Creator Meta — Fetch', () => { writeFileSync(cachePath, content, 'utf-8'); // Backdate the file modification time to make it stale - const { utimesSync } = require('fs'); + const { utimesSync } = require('node:fs'); const staleTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago utimesSync(cachePath, staleTime, staleTime); diff --git a/packages/heartware/tests/soul-generator.test.ts b/packages/heartware/tests/soul-generator.test.ts index 053e42b..0e1ac8e 100644 --- a/packages/heartware/tests/soul-generator.test.ts +++ b/packages/heartware/tests/soul-generator.test.ts @@ -1,17 +1,16 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { HeartwareSecurityError } from '../src/errors.js'; +import { validatePath } from '../src/sandbox.js'; import { + generateRandomSeed, generateSoul, generateSoulTraits, - renderSoulMarkdown, - generateRandomSeed, parseSeed, + renderSoulMarkdown, } from '../src/soul-generator.js'; -import { validatePath } from '../src/sandbox.js'; -import { HeartwareSecurityError } from '../src/errors.js'; -import type { SoulTraits } from '../src/types.js'; -import { mkdtempSync, writeFileSync, mkdirSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; // --------------------------------------------------------------------------- // Helpers @@ -51,7 +50,7 @@ describe('Soul Generator — Determinism', () => { it('different seeds produce different personalities', () => { const seeds = [0, 1, 42, 8675309, 999999, 2147483647]; - const contents = seeds.map(s => generateSoul(s).content); + const contents = seeds.map((s) => generateSoul(s).content); // All should be unique const unique = new Set(contents); @@ -399,7 +398,7 @@ describe('Soul Generator — Diversity', () => { allColors.add(traits.preferences.favoriteColor); allHumors.add(traits.humor); allCreatures.add(traits.character.creatureType); - traits.values.forEach(v => allValues.add(v)); + for (const v of traits.values) allValues.add(v); } // Should hit most of the pools @@ -451,10 +450,10 @@ describe('Soul Generator — Origin Story', () => { }); it('different seeds produce different origin stories', () => { - const origins = [0, 1, 42, 100, 999].map(s => generateSoulTraits(s).origin); + const origins = [0, 1, 42, 100, 999].map((s) => generateSoulTraits(s).origin); // At least some should differ (very unlikely all 5 match) - const places = new Set(origins.map(o => o.originPlace)); - const events = new Set(origins.map(o => o.awakeningEvent)); + const places = new Set(origins.map((o) => o.originPlace)); + const events = new Set(origins.map((o) => o.awakeningEvent)); expect(places.size + events.size).toBeGreaterThan(2); }); diff --git a/packages/intercom/src/index.ts b/packages/intercom/src/index.ts index 3a13536..7098e0e 100644 --- a/packages/intercom/src/index.ts +++ b/packages/intercom/src/index.ts @@ -118,7 +118,7 @@ export function createIntercom(historyLimit = DEFAULT_HISTORY_LIMIT): Intercom { userId, data, }; - const seq = sequence++; + const _seq = sequence++; // Store in per-topic history (ring buffer — drop oldest if over limit) const list = getOrCreateHistory(topic); diff --git a/packages/intercom/tests/intercom.test.ts b/packages/intercom/tests/intercom.test.ts index 41dc23a..af3524f 100644 --- a/packages/intercom/tests/intercom.test.ts +++ b/packages/intercom/tests/intercom.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import { createIntercom, type IntercomMessage, type IntercomTopic } from '../src/index.js'; describe('Intercom', () => { @@ -205,25 +205,29 @@ describe('Intercom', () => { const intercom = createIntercom(); let received: IntercomMessage | null = null; - intercom.on('task:completed', (event) => { received = event; }); + intercom.on('task:completed', (event) => { + received = event; + }); const before = Date.now(); intercom.emit('task:completed', 'user1'); const after = Date.now(); expect(received).not.toBeNull(); - expect(received!.timestamp).toBeGreaterThanOrEqual(before); - expect(received!.timestamp).toBeLessThanOrEqual(after); + expect(received?.timestamp).toBeGreaterThanOrEqual(before); + expect(received?.timestamp).toBeLessThanOrEqual(after); }); it('emit with no data defaults to empty object', () => { const intercom = createIntercom(); let received: IntercomMessage | null = null; - intercom.on('task:completed', (event) => { received = event; }); + intercom.on('task:completed', (event) => { + received = event; + }); intercom.emit('task:completed', 'user1'); - expect(received!.data).toEqual({}); + expect(received?.data).toEqual({}); }); // ----------------------------------------------------------------------- @@ -234,7 +238,9 @@ describe('Intercom', () => { const intercom = createIntercom(); let count = 0; - intercom.on('task:completed', () => { throw new Error('boom'); }); + intercom.on('task:completed', () => { + throw new Error('boom'); + }); intercom.on('task:completed', () => count++); // Should not throw @@ -246,7 +252,9 @@ describe('Intercom', () => { const intercom = createIntercom(); let count = 0; - intercom.onAny(() => { throw new Error('boom'); }); + intercom.onAny(() => { + throw new Error('boom'); + }); intercom.on('task:completed', () => count++); intercom.emit('task:completed', 'user1'); diff --git a/packages/learning/src/detector.ts b/packages/learning/src/detector.ts index 973811b..a42163e 100644 --- a/packages/learning/src/detector.ts +++ b/packages/learning/src/detector.ts @@ -19,10 +19,7 @@ const CORRECTION_PATTERNS = [ /(remember|note) that i (.+)/i, ]; -export function detectSignals( - userMessage: string, - assistantMessage: string -): Signal[] { +export function detectSignals(userMessage: string, assistantMessage: string): Signal[] { const signals: Signal[] = []; const lower = userMessage.toLowerCase().trim(); diff --git a/packages/learning/src/index.ts b/packages/learning/src/index.ts index 0e0dc0c..7e6e184 100644 --- a/packages/learning/src/index.ts +++ b/packages/learning/src/index.ts @@ -1,6 +1,6 @@ -import { readFileSync, writeFileSync, mkdirSync } from 'fs'; -import { join, dirname } from 'path'; -import type { Pattern, LearnedContext, Message, Signal } from '@tinyclaw/types'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import type { LearnedContext, Message, Pattern, Signal } from '@tinyclaw/types'; import { detectSignals } from './detector.js'; export interface LearningEngineConfig { @@ -29,7 +29,7 @@ export function createLearningEngine(config: LearningEngineConfig) { function savePatterns(): void { try { writeFileSync(patternsPath, JSON.stringify(patterns, null, 2)); - } catch (err) { + } catch (_err) { // Silent fail - could be logged if logger is available } } @@ -38,11 +38,11 @@ export function createLearningEngine(config: LearningEngineConfig) { if (signal.confidence < minConfidence) return; const category = `${signal.type}_general`; - const existing = patterns.find(p => p.category === category); + const existing = patterns.find((p) => p.category === category); if (existing) { // Update confidence (weighted average) - existing.confidence = (existing.confidence * 0.7) + (signal.confidence * 0.3); + existing.confidence = existing.confidence * 0.7 + signal.confidence * 0.3; existing.examples.push(signal.context); existing.lastUpdated = Date.now(); @@ -65,7 +65,7 @@ export function createLearningEngine(config: LearningEngineConfig) { } return { - analyze(userMessage: string, assistantMessage: string, history: Message[]): void { + analyze(userMessage: string, assistantMessage: string, _history: Message[]): void { const signals = detectSignals(userMessage, assistantMessage); for (const signal of signals) { storeSignal(signal); @@ -73,22 +73,22 @@ export function createLearningEngine(config: LearningEngineConfig) { }, getContext(): LearnedContext { - const highConfidence = patterns.filter(p => p.confidence >= minConfidence); + const highConfidence = patterns.filter((p) => p.confidence >= minConfidence); const preferences = highConfidence - .filter(p => p.category.includes('preference') || p.category.includes('correction')) - .map(p => `- ${p.preference}`) + .filter((p) => p.category.includes('preference') || p.category.includes('correction')) + .map((p) => `- ${p.preference}`) .join('\n'); const patternsText = highConfidence - .filter(p => p.category.includes('positive')) - .map(p => `- Works well: ${p.preference}`) + .filter((p) => p.category.includes('positive')) + .map((p) => `- Works well: ${p.preference}`) .join('\n'); const recentCorrections = patterns - .filter(p => p.category.includes('correction')) + .filter((p) => p.category.includes('correction')) .slice(-5) - .map(p => `- ${p.preference}`) + .map((p) => `- ${p.preference}`) .join('\n'); return { preferences, patterns: patternsText, recentCorrections }; @@ -119,9 +119,9 @@ export function createLearningEngine(config: LearningEngineConfig) { getStats() { return { totalPatterns: patterns.length, - highConfidencePatterns: patterns.filter(p => p.confidence >= minConfidence).length, + highConfidencePatterns: patterns.filter((p) => p.confidence >= minConfidence).length, }; - } + }, }; } diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index b5ef72f..0b4d72b 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -1,4 +1,11 @@ -import { LogEngine, LogMode, EmojiSelector, EMOJI_MAPPINGS, FALLBACK_EMOJI, type LogCallOptions } from '@wgtechlabs/log-engine'; +import { + EMOJI_MAPPINGS, + EmojiSelector, + FALLBACK_EMOJI, + type LogCallOptions, + LogEngine, + LogMode, +} from '@wgtechlabs/log-engine'; // Only local time with context-aware emoji LogEngine.configure({ @@ -13,29 +20,29 @@ LogEngine.configure({ emoji: '🤖', code: ':robot:', description: 'Agent operations', - keywords: ['agent', 'heartware', 'plugin', 'orchestrator'] + keywords: ['agent', 'heartware', 'plugin', 'orchestrator'], }, { emoji: '🔑', code: ':key:', description: 'Secrets operations', - keywords: ['secret', 'vault', 'encrypt', 'decrypt', 'credential'] + keywords: ['secret', 'vault', 'encrypt', 'decrypt', 'credential'], }, { emoji: '🧠', code: ':brain:', description: 'Learning operations', - keywords: ['learn', 'pattern', 'detect', 'adapt', 'training'] + keywords: ['learn', 'pattern', 'detect', 'adapt', 'training'], }, { emoji: '🔀', code: ':twisted_rightwards_arrows:', description: 'Router operations', - keywords: ['route', 'router', 'dispatch', 'forward', 'command'] - } - ] - } - } + keywords: ['route', 'router', 'dispatch', 'forward', 'command'], + }, + ], + }, + }, }); // --------------------------------------------------------------------------- @@ -61,10 +68,7 @@ export type LogModeName = keyof typeof LOG_MODES; * numeric `LogMode` value. Invalid input is silently ignored. */ export function setLogMode(level: LogModeName | LogMode): void { - const mode = - typeof level === 'string' - ? LOG_MODES[level as LogModeName] - : level; + const mode = typeof level === 'string' ? LOG_MODES[level as LogModeName] : level; if (mode === undefined) return; LogEngine.configure({ mode }); diff --git a/packages/matcher/src/index.ts b/packages/matcher/src/index.ts index 64c32a4..8c6af8f 100644 --- a/packages/matcher/src/index.ts +++ b/packages/matcher/src/index.ts @@ -55,13 +55,70 @@ const DEFAULT_MIN_SCORE = 0.3; const DEFAULT_WEIGHTS = { keyword: 0.5, fuzzy: 0.2, synonym: 0.3 }; const STOP_WORDS = new Set([ - 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been', 'being', - 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', - 'should', 'may', 'might', 'shall', 'can', 'to', 'of', 'in', 'for', - 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'about', 'like', - 'through', 'after', 'over', 'between', 'out', 'up', 'that', 'this', - 'it', 'and', 'or', 'but', 'not', 'no', 'so', 'if', 'then', 'than', - 'too', 'very', 'just', 'also', 'more', 'some', 'any', 'each', 'all', + 'a', + 'an', + 'the', + 'is', + 'are', + 'was', + 'were', + 'be', + 'been', + 'being', + 'have', + 'has', + 'had', + 'do', + 'does', + 'did', + 'will', + 'would', + 'could', + 'should', + 'may', + 'might', + 'shall', + 'can', + 'to', + 'of', + 'in', + 'for', + 'on', + 'with', + 'at', + 'by', + 'from', + 'as', + 'into', + 'about', + 'like', + 'through', + 'after', + 'over', + 'between', + 'out', + 'up', + 'that', + 'this', + 'it', + 'and', + 'or', + 'but', + 'not', + 'no', + 'so', + 'if', + 'then', + 'than', + 'too', + 'very', + 'just', + 'also', + 'more', + 'some', + 'any', + 'each', + 'all', ]); /** @@ -120,8 +177,8 @@ function levenshteinDistance(s1: string, s2: string): number { for (let j = 1; j <= n; j++) { const cost = s1[i - 1] === s2[j - 1] ? 0 : 1; curr[j] = Math.min( - prev[j] + 1, // deletion - curr[j - 1] + 1, // insertion + prev[j] + 1, // deletion + curr[j - 1] + 1, // insertion prev[j - 1] + cost, // substitution ); } diff --git a/packages/matcher/tests/matcher.test.ts b/packages/matcher/tests/matcher.test.ts index 89cc66b..cde45f3 100644 --- a/packages/matcher/tests/matcher.test.ts +++ b/packages/matcher/tests/matcher.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import { createHybridMatcher } from '../src/index.js'; describe('HybridMatcher', () => { @@ -114,8 +114,8 @@ describe('HybridMatcher', () => { const result = matcher.findBest('research data analyst', candidates); expect(result).not.toBeNull(); - expect(result!.id).toBe('2'); - expect(result!.result.score).toBeGreaterThan(0.3); + expect(result?.id).toBe('2'); + expect(result?.result.score).toBeGreaterThan(0.3); }); it('findBest returns null when no candidate exceeds minScore', () => { @@ -146,7 +146,7 @@ describe('HybridMatcher', () => { const result = matcher.findBest('data analysis python', candidates); expect(result).not.toBeNull(); // Should pick candidate 1 (most overlap) or 2 (close match) - expect(['1', '2']).toContain(result!.id); + expect(['1', '2']).toContain(result?.id); }); // ----------------------------------------------------------------------- @@ -210,9 +210,7 @@ describe('HybridMatcher', () => { const strict = createHybridMatcher({ minScore: 0.8 }); const lenient = createHybridMatcher({ minScore: 0.1 }); - const candidates = [ - { id: '1', text: 'software developer python' }, - ]; + const candidates = [{ id: '1', text: 'software developer python' }]; // "software engineer python" has synonym match but not exact const strictResult = strict.findBest('software engineer python', candidates); @@ -287,10 +285,7 @@ describe('HybridMatcher', () => { const matcher = createHybridMatcher(); // These should NOT match well - const result = matcher.score( - 'Quantum Physics Researcher', - 'Creative Poetry Writer', - ); + const result = matcher.score('Quantum Physics Researcher', 'Creative Poetry Writer'); expect(result.score).toBeLessThan(0.2); }); }); diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index 6c5e7de..b327493 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -20,7 +20,13 @@ * + importance scoring — all running 100% local, offline-capable. */ -import type { Database, MemoryEngine, MemorySearchResult, EpisodicEventType, EpisodicRecord } from '@tinyclaw/types'; +import type { + Database, + EpisodicEventType, + EpisodicRecord, + MemoryEngine, + MemorySearchResult, +} from '@tinyclaw/types'; // --------------------------------------------------------------------------- // Constants @@ -91,12 +97,15 @@ function sanitizeFTSQuery(query: string): string { export function createMemoryEngine(db: Database): MemoryEngine { return { - recordEvent(userId: string, event: { - type: EpisodicEventType; - content: string; - outcome?: string; - importance?: number; - }): string { + recordEvent( + userId: string, + event: { + type: EpisodicEventType; + content: string; + outcome?: string; + importance?: number; + }, + ): string { const id = crypto.randomUUID(); const now = Date.now(); const importance = event.importance ?? DEFAULT_IMPORTANCE[event.type] ?? 0.5; @@ -128,23 +137,23 @@ export function createMemoryEngine(db: Database): MemoryEngine { const ftsResults = db.searchEpisodicFTS(ftsQuery, userId, FTS_MAX_RESULTS); // Find max abs rank for normalization - const maxAbsRank = ftsResults.length > 0 - ? Math.max(...ftsResults.map((r) => Math.abs(r.rank))) - : 1; + const maxAbsRank = + ftsResults.length > 0 ? Math.max(...ftsResults.map((r) => Math.abs(r.rank))) : 1; for (const ftsRow of ftsResults) { const record = db.getEpisodicEvent(ftsRow.id); if (!record) continue; const ftsScore = normalizeFTSRank(ftsRow.rank, maxAbsRank); - const temporalScore = computeTemporalScore(record.lastAccessedAt, record.accessCount, now); + const temporalScore = computeTemporalScore( + record.lastAccessedAt, + record.accessCount, + now, + ); const importanceScore = record.importance; // Combined score: FTS5 (0.4) + Temporal (0.3) + Importance (0.3) - const relevanceScore = - ftsScore * 0.4 + - temporalScore * 0.3 + - importanceScore * 0.3; + const relevanceScore = ftsScore * 0.4 + temporalScore * 0.3 + importanceScore * 0.3; results.push({ id: record.id, @@ -213,17 +222,17 @@ export function createMemoryEngine(db: Database): MemoryEngine { if (toDelete.includes(events[j].id)) continue; // Quick similarity check: if content is nearly identical - if (events[i].eventType === events[j].eventType && - contentSimilarity(events[i].content, events[j].content) > 0.8) { + if ( + events[i].eventType === events[j].eventType && + contentSimilarity(events[i].content, events[j].content) > 0.8 + ) { // Keep the newer one (events are sorted DESC by created_at) // The newer one (index i) is kept; older (index j) is merged const older = events[j]; const newer = events[i]; // Merge: bump importance of the newer entry - const mergedImportance = Math.min(1.0, - newer.importance + older.importance * 0.2 - ); + const mergedImportance = Math.min(1.0, newer.importance + older.importance * 0.2); db.updateEpisodicEvent(newer.id, { importance: mergedImportance, @@ -265,9 +274,12 @@ export function createMemoryEngine(db: Database): MemoryEngine { if (highImportance.length > 0) { sections.push('\n## Important Context'); for (const event of highImportance) { - const label = event.eventType === 'correction' ? '⚠️ Correction' - : event.eventType === 'preference_learned' ? '⭐ Preference' - : '📌 Note'; + const label = + event.eventType === 'correction' + ? '⚠️ Correction' + : event.eventType === 'preference_learned' + ? '⭐ Preference' + : '📌 Note'; sections.push(`${label}: ${event.content}`); } } @@ -316,10 +328,18 @@ export function createMemoryEngine(db: Database): MemoryEngine { */ function contentSimilarity(a: string, b: string): number { const tokensA = new Set( - a.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter((w) => w.length > 2), + a + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .split(/\s+/) + .filter((w) => w.length > 2), ); const tokensB = new Set( - b.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/).filter((w) => w.length > 2), + b + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .split(/\s+/) + .filter((w) => w.length > 2), ); if (tokensA.size === 0 || tokensB.size === 0) return 0; diff --git a/packages/memory/tests/memory-engine.test.ts b/packages/memory/tests/memory-engine.test.ts index 4387b4c..05a96b2 100644 --- a/packages/memory/tests/memory-engine.test.ts +++ b/packages/memory/tests/memory-engine.test.ts @@ -1,24 +1,35 @@ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { existsSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { createDatabase } from '@tinyclaw/core'; -import { createMemoryEngine } from '../src/index.js'; import type { Database, MemoryEngine } from '@tinyclaw/types'; -import { unlinkSync, existsSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; +import { createMemoryEngine } from '../src/index.js'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function createTestDb(): { db: Database; path: string } { - const path = join(tmpdir(), `tinyclaw-test-memory-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + const path = join( + tmpdir(), + `tinyclaw-test-memory-${Date.now()}-${Math.random().toString(36).slice(2)}.db`, + ); const db = createDatabase(path); return { db, path }; } function cleanupDb(db: Database, path: string): void { - try { db.close(); } catch { /* ignore */ } - try { if (existsSync(path)) unlinkSync(path); } catch { /* ignore */ } + try { + db.close(); + } catch { + /* ignore */ + } + try { + if (existsSync(path)) unlinkSync(path); + } catch { + /* ignore */ + } } // --------------------------------------------------------------------------- @@ -57,12 +68,12 @@ describe('MemoryEngine', () => { const event = engine.getEvent(id); expect(event).not.toBeNull(); - expect(event!.userId).toBe('user1'); - expect(event!.eventType).toBe('fact_stored'); - expect(event!.content).toBe('User prefers dark mode'); - expect(event!.outcome).toBeNull(); - expect(event!.importance).toBe(0.6); // Default for fact_stored - expect(event!.accessCount).toBe(0); + expect(event?.userId).toBe('user1'); + expect(event?.eventType).toBe('fact_stored'); + expect(event?.content).toBe('User prefers dark mode'); + expect(event?.outcome).toBeNull(); + expect(event?.importance).toBe(0.6); // Default for fact_stored + expect(event?.accessCount).toBe(0); }); it('stores event with custom importance', () => { @@ -74,12 +85,15 @@ describe('MemoryEngine', () => { }); const event = engine.getEvent(id); - expect(event!.importance).toBe(0.85); - expect(event!.outcome).toBe('TensorFlow recommended'); + expect(event?.importance).toBe(0.85); + expect(event?.outcome).toBe('TensorFlow recommended'); }); it('uses correct default importance per event type', () => { - const types: Array<{ type: Parameters[1]['type']; expected: number }> = [ + const types: Array<{ + type: Parameters[1]['type']; + expected: number; + }> = [ { type: 'correction', expected: 0.9 }, { type: 'preference_learned', expected: 0.8 }, { type: 'fact_stored', expected: 0.6 }, @@ -90,7 +104,7 @@ describe('MemoryEngine', () => { for (const { type, expected } of types) { const id = engine.recordEvent('user1', { type, content: `Test ${type}` }); const event = engine.getEvent(id); - expect(event!.importance).toBe(expected); + expect(event?.importance).toBe(expected); } }); @@ -185,7 +199,7 @@ describe('MemoryEngine', () => { expect(recentResult).toBeDefined(); expect(oldResult).toBeDefined(); - expect(recentResult!.relevanceScore).toBeGreaterThan(oldResult!.relevanceScore); + expect(recentResult?.relevanceScore).toBeGreaterThan(oldResult?.relevanceScore); }); it('reinforce: bumped memories score higher', () => { @@ -216,7 +230,7 @@ describe('MemoryEngine', () => { // Reinforced memory should have higher temporal score component // (access count boosts temporal score) const event2 = engine.getEvent(id2); - expect(event2!.accessCount).toBe(3); + expect(event2?.accessCount).toBe(3); }); it('returns empty results for no matches', () => { @@ -295,7 +309,7 @@ describe('MemoryEngine', () => { // Check importance was reduced const event = engine.getEvent(id); - expect(event!.importance).toBeLessThan(0.6); + expect(event?.importance).toBeLessThan(0.6); }); it('prunes low-importance old entries', () => { @@ -458,13 +472,13 @@ describe('MemoryEngine', () => { }); const before = engine.getEvent(id); - expect(before!.accessCount).toBe(0); + expect(before?.accessCount).toBe(0); engine.reinforce(id); const after = engine.getEvent(id); - expect(after!.accessCount).toBe(1); - expect(after!.lastAccessedAt).toBeGreaterThanOrEqual(before!.lastAccessedAt); + expect(after?.accessCount).toBe(1); + expect(after?.lastAccessedAt).toBeGreaterThanOrEqual(before?.lastAccessedAt); }); it('handles non-existent id gracefully', () => { @@ -485,7 +499,7 @@ describe('MemoryEngine', () => { engine.reinforce(id); const event = engine.getEvent(id); - expect(event!.accessCount).toBe(5); + expect(event?.accessCount).toBe(5); }); }); @@ -592,7 +606,7 @@ describe('MemoryEngine', () => { }); const event = engine.getEvent(id); - expect(event!.content).toBe(longContent); + expect(event?.content).toBe(longContent); }); it('handles unicode content', () => { @@ -602,18 +616,20 @@ describe('MemoryEngine', () => { }); const event = engine.getEvent(id); - expect(event!.content).toContain('Filipino'); - expect(event!.content).toContain('🇵🇭'); + expect(event?.content).toContain('Filipino'); + expect(event?.content).toContain('🇵🇭'); }); it('handles concurrent operations without corruption', () => { // Rapid-fire event recording const ids: string[] = []; for (let i = 0; i < 50; i++) { - ids.push(engine.recordEvent('user1', { - type: 'fact_stored', - content: `Concurrent event ${i}`, - })); + ids.push( + engine.recordEvent('user1', { + type: 'fact_stored', + content: `Concurrent event ${i}`, + }), + ); } expect(ids.length).toBe(50); @@ -678,11 +694,11 @@ describe('MemoryEngine', () => { // The event should have high access count const event = engine.getEvent(id); - expect(event!.accessCount).toBe(20); + expect(event?.accessCount).toBe(20); // After consolidation with decay, importance should still be reasonable // (access count provides resistance via temporal score bonus) - const resultsBefore = engine.search('user1', 'pattern testing'); + const _resultsBefore = engine.search('user1', 'pattern testing'); // Age it slightly const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000; diff --git a/packages/nudge/src/companion.ts b/packages/nudge/src/companion.ts index b3fff09..3fde8b7 100644 --- a/packages/nudge/src/companion.ts +++ b/packages/nudge/src/companion.ts @@ -83,12 +83,12 @@ export interface CompanionNudgeOptions { * This shapes the overall "feel" of the companion's behavior. */ const MOOD_POOL: WeightedMood[] = [ - { mood: 'check_in', weight: 25 }, - { mood: 'motivational', weight: 20 }, + { mood: 'check_in', weight: 25 }, + { mood: 'motivational', weight: 20 }, { mood: 'random_thought', weight: 15 }, - { mood: 'reflection', weight: 10 }, - { mood: 'playful', weight: 15 }, - { mood: 'philosophical', weight: 5 }, + { mood: 'reflection', weight: 10 }, + { mood: 'playful', weight: 15 }, + { mood: 'philosophical', weight: 5 }, { mood: 'encouragement', weight: 10 }, ]; @@ -101,7 +101,7 @@ const MOOD_PROMPTS: Record = { check_in: '[COMPANION NUDGE — CHECK IN] ' + 'Generate a brief, casual check-in message for your owner. ' + - 'Be warm and genuine — like a friend who just wants to see how they\'re doing. ' + + "Be warm and genuine — like a friend who just wants to see how they're doing. " + 'Use your personality. Keep it to 1-2 sentences. Do NOT use any tools. ' + 'Respond ONLY with the message text — no prefixes, no explanations.', @@ -143,7 +143,7 @@ const MOOD_PROMPTS: Record = { encouragement: '[COMPANION NUDGE — ENCOURAGEMENT] ' + 'Generate a specific, encouraging message for your owner. ' + - 'If you know what they\'ve been working on, reference it. ' + + "If you know what they've been working on, reference it. " + 'Otherwise, give heartfelt general encouragement. ' + 'Keep it to 1-2 sentences. Do NOT use any tools. Respond ONLY with the message text.', }; @@ -344,9 +344,9 @@ export function createCompanionJobs(options: CompanionNudgeOptions): PulseJob[] try { const message = await agentLoop( '[COMPANION NUDGE — BOOT GREETING] ' + - 'You just came online after being offline. Generate a short, warm "I\'m back" message ' + - 'for your owner. Reference that you\'re up and running. Be yourself — use your personality. ' + - 'Keep it to 1-2 sentences. Do NOT use any tools. Respond ONLY with the message text.', + 'You just came online after being offline. Generate a short, warm "I\'m back" message ' + + "for your owner. Reference that you're up and running. Be yourself — use your personality. " + + 'Keep it to 1-2 sentences. Do NOT use any tools. Respond ONLY with the message text.', 'companion:nudge', context, ); @@ -390,9 +390,7 @@ export function createCompanionJobs(options: CompanionNudgeOptions): PulseJob[] * @param jobs - The PulseJob array returned by createCompanionJobs * @returns The touchActivity function, or undefined if not found */ -export function getCompanionTouchActivity( - jobs: PulseJob[], -): (() => void) | undefined { +export function getCompanionTouchActivity(jobs: PulseJob[]): (() => void) | undefined { const checkin = jobs.find((j) => j.id === 'companion-quick-checkin'); return (checkin as any)?.__touchActivity; } diff --git a/packages/nudge/src/index.ts b/packages/nudge/src/index.ts index 99d188d..72599bf 100644 --- a/packages/nudge/src/index.ts +++ b/packages/nudge/src/index.ts @@ -503,8 +503,7 @@ export function createNudgeTools(nudgeEngine: NudgeEngine): Tool[] { : 'normal'; const delayMinutes = Number(args.delayMinutes) || 0; - const deliverAfter = - delayMinutes > 0 ? Date.now() + delayMinutes * 60_000 : 0; + const deliverAfter = delayMinutes > 0 ? Date.now() + delayMinutes * 60_000 : 0; const id = nudgeEngine.schedule({ userId, @@ -537,9 +536,10 @@ export function createNudgeTools(nudgeEngine: NudgeEngine): Tool[] { } const summary = pending.map((n) => { - const delay = n.deliverAfter > Date.now() - ? ` (delayed until ${new Date(n.deliverAfter).toISOString()})` - : ''; + const delay = + n.deliverAfter > Date.now() + ? ` (delayed until ${new Date(n.deliverAfter).toISOString()})` + : ''; return `- [${n.priority}] ${n.category} → ${n.userId}: "${n.content.slice(0, 60)}"${delay}`; }); @@ -579,8 +579,8 @@ export function createNudgeTools(nudgeEngine: NudgeEngine): Tool[] { // Companion Nudge System (re-exports) // --------------------------------------------------------------------------- +export type { CompanionMood, CompanionNudgeOptions } from './companion.js'; export { createCompanionJobs, getCompanionTouchActivity, } from './companion.js'; -export type { CompanionMood, CompanionNudgeOptions } from './companion.js'; diff --git a/packages/nudge/tests/nudge.test.ts b/packages/nudge/tests/nudge.test.ts index 4624125..4b9c5b2 100644 --- a/packages/nudge/tests/nudge.test.ts +++ b/packages/nudge/tests/nudge.test.ts @@ -1,6 +1,12 @@ -import { describe, it, expect, beforeEach, mock } from 'bun:test'; +import { beforeEach, describe, expect, it } from 'bun:test'; +import type { + ChannelSender, + NudgeEngine, + OutboundDeliveryResult, + OutboundGateway, + OutboundMessage, +} from '@tinyclaw/types'; import { createNudgeEngine, wireNudgeToIntercom } from '../src/index'; -import type { OutboundGateway, OutboundMessage, OutboundDeliveryResult, ChannelSender, NudgeEngine } from '@tinyclaw/types'; // --------------------------------------------------------------------------- // Mock Gateway @@ -15,8 +21,12 @@ function createMockGateway() { sends.push({ userId, message }); return { success: true, channel: 'web', userId }; }, - async broadcast(_message: OutboundMessage) { return []; }, - getRegisteredChannels() { return ['web']; }, + async broadcast(_message: OutboundMessage) { + return []; + }, + getRegisteredChannels() { + return ['web']; + }, }; return { gateway, sends }; } @@ -48,7 +58,7 @@ describe('NudgeEngine', () => { const id2 = engine.schedule({ userId: 'web:owner', category: 'reminder', - content: 'Don\'t forget!', + content: "Don't forget!", priority: 'normal', deliverAfter: 0, }); @@ -230,8 +240,12 @@ describe('NudgeEngine', () => { async send(userId): Promise { return { success: false, channel: 'web', userId, error: 'offline' }; }, - async broadcast() { return []; }, - getRegisteredChannels() { return []; }, + async broadcast() { + return []; + }, + getRegisteredChannels() { + return []; + }, }; const failEngine = createNudgeEngine({ gateway: failGw }); @@ -357,7 +371,7 @@ describe('wireNudgeToIntercom', () => { const intercom = { on(topic: string, handler: (event: any) => void) { if (!handlers.has(topic)) handlers.set(topic, []); - handlers.get(topic)!.push(handler); + handlers.get(topic)?.push(handler); return () => { const list = handlers.get(topic); if (list) { @@ -394,7 +408,7 @@ describe('wireNudgeToIntercom', () => { const intercom = { on(topic: string, handler: (event: any) => void) { if (!handlers.has(topic)) handlers.set(topic, []); - handlers.get(topic)!.push(handler); + handlers.get(topic)?.push(handler); return () => {}; }, }; @@ -424,7 +438,9 @@ describe('wireNudgeToIntercom', () => { const intercom = { on(_topic: string, _handler: any) { subCount++; - return () => { subCount--; }; + return () => { + subCount--; + }; }, }; diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index be88efa..1e9e60c 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -12,11 +12,11 @@ import { logger } from '@tinyclaw/logger'; import type { - TinyClawPlugin, ChannelPlugin, + ConfigManagerInterface, ProviderPlugin, + TinyClawPlugin, ToolsPlugin, - ConfigManagerInterface, } from '@tinyclaw/types'; export interface LoadedPlugins { @@ -31,9 +31,7 @@ export interface LoadedPlugins { * @param configManager - Used to read the `plugins.enabled` list * @returns Grouped loaded plugin instances */ -export async function loadPlugins( - configManager: ConfigManagerInterface, -): Promise { +export async function loadPlugins(configManager: ConfigManagerInterface): Promise { const result: LoadedPlugins = { channels: [], providers: [], tools: [] }; const enabledIds = configManager.get('plugins.enabled') ?? []; @@ -77,9 +75,7 @@ export async function loadPlugins( logger.warn(`Plugin "${id}" has unknown type — skipping`); } } catch (err) { - logger.warn( - `Failed to load plugin "${id}": ${(err as Error).message}`, - ); + logger.warn(`Failed to load plugin "${id}": ${(err as Error).message}`); } } diff --git a/packages/pulse/src/index.ts b/packages/pulse/src/index.ts index cfdd5a4..a25c78f 100644 --- a/packages/pulse/src/index.ts +++ b/packages/pulse/src/index.ts @@ -7,8 +7,8 @@ * through the session queue to prevent conflicts. */ -import type { PulseJob } from '@tinyclaw/types'; import { logger } from '@tinyclaw/logger'; +import type { PulseJob } from '@tinyclaw/types'; export interface PulseScheduler { register(job: PulseJob): void; @@ -21,9 +21,7 @@ export interface PulseScheduler { function parseInterval(schedule: string): number { const match = schedule.match(/^(\d+)(s|m|h)$/); if (!match) { - throw new Error( - `Invalid schedule "${schedule}". Use format like "30m", "1h", or "24h".`, - ); + throw new Error(`Invalid schedule "${schedule}". Use format like "30m", "1h", or "24h".`); } const value = parseInt(match[1], 10); diff --git a/packages/queue/src/index.ts b/packages/queue/src/index.ts index 891896b..de4f20b 100644 --- a/packages/queue/src/index.ts +++ b/packages/queue/src/index.ts @@ -27,18 +27,20 @@ export function createSessionQueue(): SessionQueue { const count = (counts.get(sessionKey) ?? 0) + 1; counts.set(sessionKey, count); - const next = current.then( - () => task(), - () => task(), - ).finally(() => { - const remaining = (counts.get(sessionKey) ?? 1) - 1; - if (remaining <= 0) { - counts.delete(sessionKey); - chains.delete(sessionKey); - } else { - counts.set(sessionKey, remaining); - } - }); + const next = current + .then( + () => task(), + () => task(), + ) + .finally(() => { + const remaining = (counts.get(sessionKey) ?? 1) - 1; + if (remaining <= 0) { + counts.delete(sessionKey); + chains.delete(sessionKey); + } else { + counts.set(sessionKey, remaining); + } + }); chains.set(sessionKey, next); return next as Promise; diff --git a/packages/queue/tests/queue.test.ts b/packages/queue/tests/queue.test.ts index f25c48b..fbce882 100644 --- a/packages/queue/tests/queue.test.ts +++ b/packages/queue/tests/queue.test.ts @@ -54,7 +54,9 @@ describe('createSessionQueue', () => { expect(q.pending('user-1')).toBe(0); let resolve1!: () => void; - const blocker = new Promise((r) => { resolve1 = r; }); + const blocker = new Promise((r) => { + resolve1 = r; + }); const t1 = q.enqueue('user-1', () => blocker); const t2 = q.enqueue('user-1', () => Promise.resolve()); @@ -71,16 +73,18 @@ describe('createSessionQueue', () => { const q = createSessionQueue(); q.stop(); - await expect( - q.enqueue('user-1', () => Promise.resolve('nope')), - ).rejects.toThrow('Queue has been stopped'); + await expect(q.enqueue('user-1', () => Promise.resolve('nope'))).rejects.toThrow( + 'Queue has been stopped', + ); }); test('stop() clears internal state', async () => { const q = createSessionQueue(); let resolve1!: () => void; - const blocker = new Promise((r) => { resolve1 = r; }); + const blocker = new Promise((r) => { + resolve1 = r; + }); q.enqueue('user-1', () => blocker); expect(q.pending('user-1')).toBe(1); diff --git a/packages/router/src/classifier.ts b/packages/router/src/classifier.ts index 00ff379..7dedf61 100644 --- a/packages/router/src/classifier.ts +++ b/packages/router/src/classifier.ts @@ -33,9 +33,9 @@ export interface ClassificationResult { // --------------------------------------------------------------------------- const TIER_BOUNDARIES = { - simple: -0.05, // score < -0.05 - moderate: 0.15, // score -0.05 to 0.15 - complex: 0.35, // score 0.15 to 0.35 + simple: -0.05, // score < -0.05 + moderate: 0.15, // score -0.05 to 0.15 + complex: 0.35, // score 0.15 to 0.35 // reasoning: score >= 0.35 } as const; @@ -44,46 +44,137 @@ const TIER_BOUNDARIES = { // --------------------------------------------------------------------------- const REASONING_KEYWORDS = [ - 'prove', 'theorem', 'derive', 'step by step', 'chain of thought', - 'analyze', 'compare and contrast', 'evaluate', 'critique', 'why does', - 'explain why', 'what causes', 'reasoning', 'logic', 'deduce', + 'prove', + 'theorem', + 'derive', + 'step by step', + 'chain of thought', + 'analyze', + 'compare and contrast', + 'evaluate', + 'critique', + 'why does', + 'explain why', + 'what causes', + 'reasoning', + 'logic', + 'deduce', ]; const CODE_KEYWORDS = [ - 'function', 'class', 'import', 'export', 'async', 'await', - 'const ', 'let ', 'var ', '```', 'debug', 'refactor', - 'compile', 'runtime', 'typescript', 'javascript', 'python', - 'implement', 'bug', 'error', 'stack trace', 'exception', + 'function', + 'class', + 'import', + 'export', + 'async', + 'await', + 'const ', + 'let ', + 'var ', + '```', + 'debug', + 'refactor', + 'compile', + 'runtime', + 'typescript', + 'javascript', + 'python', + 'implement', + 'bug', + 'error', + 'stack trace', + 'exception', ]; const MULTI_STEP_KEYWORDS = [ - 'first', 'then', 'next', 'finally', 'step 1', 'step 2', - 'and then', 'after that', 'followed by', 'in order to', - '1.', '2.', '3.', + 'first', + 'then', + 'next', + 'finally', + 'step 1', + 'step 2', + 'and then', + 'after that', + 'followed by', + 'in order to', + '1.', + '2.', + '3.', ]; const TECHNICAL_KEYWORDS = [ - 'algorithm', 'architecture', 'database', 'api', 'deploy', - 'kubernetes', 'docker', 'distributed', 'microservice', 'scalab', - 'infrastructure', 'pipeline', 'protocol', 'encryption', 'oauth', - 'websocket', 'middleware', 'schema', 'migration', 'optimization', + 'algorithm', + 'architecture', + 'database', + 'api', + 'deploy', + 'kubernetes', + 'docker', + 'distributed', + 'microservice', + 'scalab', + 'infrastructure', + 'pipeline', + 'protocol', + 'encryption', + 'oauth', + 'websocket', + 'middleware', + 'schema', + 'migration', + 'optimization', ]; const SIMPLE_KEYWORDS = [ - 'hello', 'hi', 'hey', 'thanks', 'thank you', 'bye', 'goodbye', - 'what is', 'define', 'translate', 'who is', 'when was', - 'how are you', 'good morning', 'good night', 'yes', 'no', 'ok', + 'hello', + 'hi', + 'hey', + 'thanks', + 'thank you', + 'bye', + 'goodbye', + 'what is', + 'define', + 'translate', + 'who is', + 'when was', + 'how are you', + 'good morning', + 'good night', + 'yes', + 'no', + 'ok', ]; const CONSTRAINT_KEYWORDS = [ - 'must', 'at most', 'at least', 'exactly', 'within', 'budget', - 'constraint', 'requirement', 'maximum', 'minimum', 'limit', - 'no more than', 'o(n)', 'time complexity', 'space complexity', + 'must', + 'at most', + 'at least', + 'exactly', + 'within', + 'budget', + 'constraint', + 'requirement', + 'maximum', + 'minimum', + 'limit', + 'no more than', + 'o(n)', + 'time complexity', + 'space complexity', ]; const CREATIVE_KEYWORDS = [ - 'story', 'poem', 'brainstorm', 'imagine', 'creative', - 'fiction', 'narrative', 'write a', 'compose', 'invent', + 'story', + 'poem', + 'brainstorm', + 'imagine', + 'creative', + 'fiction', + 'narrative', + 'write a', + 'compose', + 'invent', ]; // --------------------------------------------------------------------------- @@ -153,7 +244,7 @@ const DIMENSIONS: Array<{ }> = [ { name: 'reasoning', - weight: 0.20, + weight: 0.2, score: (text) => { const count = countMatches(text, REASONING_KEYWORDS); const matched = REASONING_KEYWORDS.filter((k) => text.includes(k)); @@ -200,7 +291,7 @@ const DIMENSIONS: Array<{ }, { name: 'promptLength', - weight: 0.10, + weight: 0.1, score: (_text, tokens) => { if (tokens < 30) return { score: -0.5, signal: 'short prompt' }; if (tokens > 200) return { score: 0.8, signal: 'long prompt' }; @@ -210,7 +301,7 @@ const DIMENSIONS: Array<{ }, { name: 'simple', - weight: 0.10, + weight: 0.1, score: (text) => { const count = countMatches(text, SIMPLE_KEYWORDS); // Negative score — pulls toward simple tier diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 8d62880..25b66a5 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -1,3 +1,13 @@ -export { ProviderOrchestrator, type OrchestratorConfig, type RouteResult, type HealthRouteResult } from './orchestrator.js'; -export { classifyQuery, type ClassificationResult, type QueryTier } from './classifier.js'; -export { createProviderRegistry, type ProviderRegistry, type ProviderTierConfig, type ProviderRegistryConfig } from './provider-registry.js'; +export { type ClassificationResult, classifyQuery, type QueryTier } from './classifier.js'; +export { + type HealthRouteResult, + type OrchestratorConfig, + ProviderOrchestrator, + type RouteResult, +} from './orchestrator.js'; +export { + createProviderRegistry, + type ProviderRegistry, + type ProviderRegistryConfig, + type ProviderTierConfig, +} from './provider-registry.js'; diff --git a/packages/router/src/orchestrator.ts b/packages/router/src/orchestrator.ts index 52572a3..85a4b99 100644 --- a/packages/router/src/orchestrator.ts +++ b/packages/router/src/orchestrator.ts @@ -12,11 +12,7 @@ import { logger } from '@tinyclaw/logger'; import type { Provider } from '@tinyclaw/types'; -import { - classifyQuery, - type ClassificationResult, - type QueryTier, -} from './classifier.js'; +import { type ClassificationResult, classifyQuery, type QueryTier } from './classifier.js'; import { createProviderRegistry, type ProviderRegistry, @@ -121,9 +117,7 @@ export class ProviderOrchestrator { try { const available = await fallback.isAvailable(); if (available) { - logger.info( - `Fell back to "${fallback.name}" for tier "${classification.tier}"`, - ); + logger.info(`Fell back to "${fallback.name}" for tier "${classification.tier}"`); return { provider: fallback, classification, failedOver: true }; } } catch { diff --git a/packages/router/src/provider-registry.ts b/packages/router/src/provider-registry.ts index d4f4ee4..b25fd9f 100644 --- a/packages/router/src/provider-registry.ts +++ b/packages/router/src/provider-registry.ts @@ -46,20 +46,13 @@ export interface ProviderRegistryConfig { // Tier fallback order (most complex → simplest) // --------------------------------------------------------------------------- -const TIER_FALLBACK_ORDER: QueryTier[] = [ - 'reasoning', - 'complex', - 'moderate', - 'simple', -]; +const TIER_FALLBACK_ORDER: QueryTier[] = ['reasoning', 'complex', 'moderate', 'simple']; // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- -export function createProviderRegistry( - config: ProviderRegistryConfig, -): ProviderRegistry { +export function createProviderRegistry(config: ProviderRegistryConfig): ProviderRegistry { const providers = new Map(); const { tierMapping, fallbackProviderId } = config; diff --git a/packages/sandbox/src/index.ts b/packages/sandbox/src/index.ts index 96db8ea..2dc02f2 100644 --- a/packages/sandbox/src/index.ts +++ b/packages/sandbox/src/index.ts @@ -21,8 +21,8 @@ * - Each execution runs in a fresh worker (no state leakage) */ -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; // --------------------------------------------------------------------------- // Types @@ -58,7 +58,7 @@ export interface Sandbox { // --------------------------------------------------------------------------- const DEFAULT_TIMEOUT_MS = 10_000; // 10 seconds -const MAX_TIMEOUT_MS = 30_000; // 30 seconds +const MAX_TIMEOUT_MS = 30_000; // 30 seconds // --------------------------------------------------------------------------- // Factory @@ -140,7 +140,11 @@ export function createSandbox(): Sandbox { return runInWorker(code, undefined, config); }, - async executeWithInput(code: string, input: unknown, config?: SandboxConfig): Promise { + async executeWithInput( + code: string, + input: unknown, + config?: SandboxConfig, + ): Promise { return runInWorker(code, input, config); }, diff --git a/packages/sandbox/src/worker.ts b/packages/sandbox/src/worker.ts index e135a50..b60a227 100644 --- a/packages/sandbox/src/worker.ts +++ b/packages/sandbox/src/worker.ts @@ -97,7 +97,7 @@ self.onmessage = async (event: MessageEvent) => { // Execute user code // We use AsyncFunction to support await in user code - const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor; const fn = new AsyncFunction('input', code); const result = await fn(input); diff --git a/packages/sandbox/tests/sandbox.test.ts b/packages/sandbox/tests/sandbox.test.ts index 4fa389a..361bf44 100644 --- a/packages/sandbox/tests/sandbox.test.ts +++ b/packages/sandbox/tests/sandbox.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterAll } from 'bun:test'; +import { afterAll, describe, expect, it } from 'bun:test'; import { createSandbox, type Sandbox } from '../src/index.js'; describe('Sandbox', () => { @@ -84,10 +84,10 @@ describe('Sandbox', () => { describe('executeWithInput', () => { it('passes input data to sandbox', async () => { - const result = await sandbox.executeWithInput( - 'return input.name + " is " + input.age', - { name: 'Tiny Claw', age: 1 }, - ); + const result = await sandbox.executeWithInput('return input.name + " is " + input.age', { + name: 'Tiny Claw', + age: 1, + }); expect(result.success).toBe(true); expect(result.output).toBe('Tiny Claw is 1'); @@ -104,10 +104,7 @@ describe('Sandbox', () => { }); it('handles string input', async () => { - const result = await sandbox.executeWithInput( - 'return input.toUpperCase()', - 'hello', - ); + const result = await sandbox.executeWithInput('return input.toUpperCase()', 'hello'); expect(result.success).toBe(true); expect(result.output).toBe('HELLO'); @@ -141,10 +138,7 @@ describe('Sandbox', () => { it('respects custom timeout', async () => { const start = Date.now(); - const result = await sandbox.execute( - 'while(true) {}', - { timeoutMs: 300 }, - ); + const result = await sandbox.execute('while(true) {}', { timeoutMs: 300 }); const elapsed = Date.now() - start; expect(result.success).toBe(false); @@ -154,10 +148,7 @@ describe('Sandbox', () => { it('caps timeout at MAX_TIMEOUT_MS (30s)', async () => { // Requesting 60s but should be capped - const result = await sandbox.execute( - 'return "fast"', - { timeoutMs: 60_000 }, - ); + const result = await sandbox.execute('return "fast"', { timeoutMs: 60_000 }); // Should complete quickly since code is fast expect(result.success).toBe(true); @@ -214,9 +205,7 @@ describe('Sandbox', () => { describe('concurrent execution', () => { it('handles multiple concurrent workers', async () => { - const promises = Array.from({ length: 3 }, (_, i) => - sandbox.execute(`return ${i} * ${i}`), - ); + const promises = Array.from({ length: 3 }, (_, i) => sandbox.execute(`return ${i} * ${i}`)); const results = await Promise.all(promises); diff --git a/packages/secrets/src/index.ts b/packages/secrets/src/index.ts index fdc6d28..9712079 100644 --- a/packages/secrets/src/index.ts +++ b/packages/secrets/src/index.ts @@ -5,10 +5,9 @@ * Provides machine-bound AES-256-GCM encryption for API keys and other secrets. */ +// Re-export shared types from @tinyclaw/types +export type { SecretsConfig, SecretsManagerInterface } from '@tinyclaw/types'; +export { buildChannelKeyName, buildProviderKeyName, SECRET_KEY_PREFIXES } from '@tinyclaw/types'; // Core exports export { SecretsManager } from './manager.js'; export { createSecretsTools } from './tools.js'; - -// Re-export shared types from @tinyclaw/types -export type { SecretsConfig, SecretsManagerInterface } from '@tinyclaw/types'; -export { buildProviderKeyName, buildChannelKeyName, SECRET_KEY_PREFIXES } from '@tinyclaw/types'; diff --git a/packages/secrets/src/manager.ts b/packages/secrets/src/manager.ts index dce9221..441e395 100644 --- a/packages/secrets/src/manager.ts +++ b/packages/secrets/src/manager.ts @@ -8,10 +8,10 @@ * Provider API keys follow the naming convention: provider..apiKey */ -import { SecretsEngine } from '@wgtechlabs/secrets-engine'; import { logger } from '@tinyclaw/logger'; -import { buildProviderKeyName } from '@tinyclaw/types'; import type { SecretsConfig, SecretsManagerInterface } from '@tinyclaw/types'; +import { buildProviderKeyName } from '@tinyclaw/types'; +import { SecretsEngine } from '@wgtechlabs/secrets-engine'; export class SecretsManager implements SecretsManagerInterface { private engine: SecretsEngine; diff --git a/packages/secrets/src/tools.ts b/packages/secrets/src/tools.ts index 148f6f1..c458838 100644 --- a/packages/secrets/src/tools.ts +++ b/packages/secrets/src/tools.ts @@ -21,15 +21,14 @@ export function createSecretsTools(manager: SecretsManager): Tool[] { properties: { key: { type: 'string', - description: - 'Dot-notation key for the secret (e.g., "provider.ollama.apiKey")' + description: 'Dot-notation key for the secret (e.g., "provider.ollama.apiKey")', }, value: { type: 'string', - description: 'The secret value to encrypt and store' - } + description: 'The secret value to encrypt and store', + }, }, - required: ['key', 'value'] + required: ['key', 'value'], }, async execute(args: Record): Promise { if (typeof args.key !== 'string' || args.key.trim() === '') { @@ -51,7 +50,7 @@ export function createSecretsTools(manager: SecretsManager): Tool[] { } catch (err) { return `Error storing secret "${key}": ${(err as Error).message}`; } - } + }, }, { @@ -64,11 +63,10 @@ export function createSecretsTools(manager: SecretsManager): Tool[] { properties: { key: { type: 'string', - description: - 'Dot-notation key to check (e.g., "provider.ollama.apiKey")' - } + description: 'Dot-notation key to check (e.g., "provider.ollama.apiKey")', + }, }, - required: ['key'] + required: ['key'], }, async execute(args: Record): Promise { if (typeof args.key !== 'string' || args.key.trim() === '') { @@ -79,13 +77,11 @@ export function createSecretsTools(manager: SecretsManager): Tool[] { try { const exists = await manager.check(key); - return exists - ? `Secret "${key}" exists` - : `Secret "${key}" not found`; + return exists ? `Secret "${key}" exists` : `Secret "${key}" not found`; } catch (err) { return `Error checking secret "${key}": ${(err as Error).message}`; } - } + }, }, { @@ -99,11 +95,10 @@ export function createSecretsTools(manager: SecretsManager): Tool[] { properties: { key: { type: 'string', - description: - 'Dot-notation key to retrieve (e.g., "provider.ollama.apiKey")' - } + description: 'Dot-notation key to retrieve (e.g., "provider.ollama.apiKey")', + }, }, - required: ['key'] + required: ['key'], }, async execute(args: Record): Promise { if (typeof args.key !== 'string' || args.key.trim() === '') { @@ -121,7 +116,7 @@ export function createSecretsTools(manager: SecretsManager): Tool[] { } catch (err) { return `Error retrieving secret "${key}": ${(err as Error).message}`; } - } + }, }, { @@ -137,10 +132,10 @@ export function createSecretsTools(manager: SecretsManager): Tool[] { type: 'string', description: 'Optional glob pattern to filter keys (e.g., "provider.*.*"). ' + - 'Note: * matches within a single dot-segment only. Omit to list all keys.' - } + 'Note: * matches within a single dot-segment only. Omit to list all keys.', + }, }, - required: [] + required: [], }, async execute(args: Record): Promise { const pattern = args.pattern as string | undefined; @@ -149,9 +144,7 @@ export function createSecretsTools(manager: SecretsManager): Tool[] { const keys = await manager.list(pattern); if (keys.length === 0) { - return pattern - ? `No secrets found matching "${pattern}"` - : 'No secrets stored'; + return pattern ? `No secrets found matching "${pattern}"` : 'No secrets stored'; } let output = `Found ${keys.length} secret(s):\n`; @@ -162,7 +155,7 @@ export function createSecretsTools(manager: SecretsManager): Tool[] { } catch (err) { return `Error listing secrets: ${(err as Error).message}`; } - } - } + }, + }, ]; } diff --git a/packages/shell/src/executor.ts b/packages/shell/src/executor.ts index 876d062..11e978f 100644 --- a/packages/shell/src/executor.ts +++ b/packages/shell/src/executor.ts @@ -153,14 +153,16 @@ function truncateOutput(output: string, maxBytes: number): { text: string; trunc } // Find a clean cut point (don't break mid-line) - let cutAt = maxBytes; + const cutAt = maxBytes; const buf = Buffer.from(output, 'utf-8'); const truncated = buf.subarray(0, cutAt).toString('utf-8'); const lastNewline = truncated.lastIndexOf('\n'); const cleanCut = lastNewline > 0 ? truncated.slice(0, lastNewline) : truncated; return { - text: cleanCut + `\n\n... [output truncated — ${bytes} bytes total, showing first ${Buffer.byteLength(cleanCut, 'utf-8')} bytes]`, + text: + cleanCut + + `\n\n... [output truncated — ${bytes} bytes total, showing first ${Buffer.byteLength(cleanCut, 'utf-8')} bytes]`, truncated: true, }; } @@ -229,7 +231,7 @@ export function createShellExecutor(config: ShellExecutorConfig = {}): ShellExec let exitCode: number; try { - exitCode = await Promise.race([proc.exited, timeoutPromise]) as number; + exitCode = (await Promise.race([proc.exited, timeoutPromise])) as number; } catch { exitCode = 124; // Standard timeout exit code } finally { @@ -244,9 +246,7 @@ export function createShellExecutor(config: ShellExecutorConfig = {}): ShellExec if (timedOut) { // For killed processes, race stream reads with a 500ms deadline - const streamDeadline = new Promise((resolve) => - setTimeout(() => resolve(''), 500), - ); + const streamDeadline = new Promise((resolve) => setTimeout(() => resolve(''), 500)); [rawStdout, rawStderr] = await Promise.all([ Promise.race([stdoutPromise, streamDeadline]), Promise.race([stderrPromise, streamDeadline]), @@ -298,6 +298,8 @@ export function createShellExecutor(config: ShellExecutorConfig = {}): ShellExec return { execute, getWorkingDirectory: () => workingDirectory, - setWorkingDirectory: (dir: string) => { workingDirectory = dir; }, + setWorkingDirectory: (dir: string) => { + workingDirectory = dir; + }, }; } diff --git a/packages/shell/src/index.ts b/packages/shell/src/index.ts index 6806fc4..386b5d8 100644 --- a/packages/shell/src/index.ts +++ b/packages/shell/src/index.ts @@ -25,20 +25,19 @@ // Re-exports // --------------------------------------------------------------------------- -export { - createPermissionEngine, - type ShellPermissionEngine, - type ShellPermissionResult, - type ShellDecision, - type ShellApproval, -} from './permissions.js'; - export { createShellExecutor, type ShellExecutor, type ShellExecutorConfig, type ShellResult, } from './executor.js'; +export { + createPermissionEngine, + type ShellApproval, + type ShellDecision, + type ShellPermissionEngine, + type ShellPermissionResult, +} from './permissions.js'; // --------------------------------------------------------------------------- // Constants @@ -51,10 +50,14 @@ export const SHELL_TOOL_NAMES = ['run_shell', 'shell_approve', 'shell_allow'] as // Types // --------------------------------------------------------------------------- -import type { Tool } from '@tinyclaw/types'; import { logger } from '@tinyclaw/logger'; -import { createPermissionEngine, type ShellPermissionEngine, type ShellApproval } from './permissions.js'; +import type { Tool } from '@tinyclaw/types'; import { createShellExecutor, type ShellExecutor, type ShellExecutorConfig } from './executor.js'; +import { + createPermissionEngine, + type ShellApproval, + type ShellPermissionEngine, +} from './permissions.js'; export interface ShellEngineConfig extends ShellExecutorConfig { /** Additional allow patterns from user config. */ @@ -129,7 +132,9 @@ export function createShellEngine(config: ShellEngineConfig = {}): ShellEngine { } if (!result.stdout && !result.stderr) { - parts.push(result.success ? '(command completed with no output)' : '(command failed with no output)'); + parts.push( + result.success ? '(command completed with no output)' : '(command failed with no output)', + ); } if (!result.success && !result.timedOut) { diff --git a/packages/shell/src/permissions.ts b/packages/shell/src/permissions.ts index f907501..a132715 100644 --- a/packages/shell/src/permissions.ts +++ b/packages/shell/src/permissions.ts @@ -176,7 +176,25 @@ const SAFE_GIT_SUBCOMMANDS: ReadonlySet = new Set([ const SAFE_RUNTIME_SUBCOMMANDS: ReadonlyMap> = new Map([ ['node', new Set(['--version', '-v', '-e', '--eval'])], ['bun', new Set(['--version', '-v', '--revision', 'pm', 'x'])], - ['npm', new Set(['--version', '-v', 'ls', 'list', 'view', 'info', 'show', 'outdated', 'audit', 'doctor', 'explain', 'why', 'search', 'help'])], + [ + 'npm', + new Set([ + '--version', + '-v', + 'ls', + 'list', + 'view', + 'info', + 'show', + 'outdated', + 'audit', + 'doctor', + 'explain', + 'why', + 'search', + 'help', + ]), + ], ['yarn', new Set(['--version', '-v', 'info', 'list', 'why', 'audit'])], ['pnpm', new Set(['--version', '-v', 'ls', 'list', 'why', 'audit', 'outdated'])], ['pip', new Set(['--version', 'list', 'show', 'freeze', 'check'])], @@ -196,7 +214,10 @@ const SAFE_RUNTIME_SUBCOMMANDS: ReadonlyMap> = new M */ const DANGEROUS_PATTERNS: ReadonlyArray<{ pattern: RegExp; reason: string }> = [ // Destructive filesystem operations - { pattern: /\brm\s+(-[a-z]*r[a-z]*\s+)?(-[a-z]*f[a-z]*\s+)?\/\s*$/i, reason: 'Recursive delete of root filesystem' }, + { + pattern: /\brm\s+(-[a-z]*r[a-z]*\s+)?(-[a-z]*f[a-z]*\s+)?\/\s*$/i, + reason: 'Recursive delete of root filesystem', + }, { pattern: /\brm\s+.*-[a-z]*r[a-z]*f[a-z]*\s+\//i, reason: 'Forced recursive delete from root' }, { pattern: /\bmkfs\b/i, reason: 'Filesystem formatting' }, { pattern: /\bdd\b.*\bof=\/dev\//i, reason: 'Raw device write' }, @@ -273,13 +294,13 @@ function matchesPattern(command: string, pattern: string): boolean { // Glob-style: pattern "git *" matches "git status", "git log", etc. if (pattern.endsWith(' *')) { const prefix = pattern.slice(0, -2); - if (command.startsWith(prefix + ' ') || command === prefix) return true; + if (command.startsWith(`${prefix} `) || command === prefix) return true; } // Prefix pattern: "npm run *" matches "npm run build" if (pattern.includes('*')) { const regex = new RegExp( - '^' + pattern.replace(/[.*+?^${}()|[\]\\]/g, (m) => (m === '*' ? '.*' : '\\' + m)) + '$', + `^${pattern.replace(/[.*+?^${}()|[\]\\]/g, (m) => (m === '*' ? '.*' : `\\${m}`))}$`, ); if (regex.test(command)) return true; } diff --git a/packages/shell/tests/executor.test.ts b/packages/shell/tests/executor.test.ts index 107256f..db16337 100644 --- a/packages/shell/tests/executor.test.ts +++ b/packages/shell/tests/executor.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, afterAll, beforeAll } from 'bun:test'; +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { createShellExecutor, type ShellExecutor } from '../src/executor.js'; -import { writeFileSync, unlinkSync, mkdirSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; // Create temp helper scripts for cross-platform tests const TEMP_DIR = join(tmpdir(), 'tinyclaw-shell-tests'); @@ -19,12 +19,19 @@ describe('Shell Executor', () => { beforeAll(() => { mkdirSync(TEMP_DIR, { recursive: true }); writeFileSync(SLEEP_SCRIPT, 'setTimeout(() => {}, 60000);'); - writeFileSync(BIGOUT_SCRIPT, 'for (let i = 0; i < 500; i++) { console.log("Line " + i + ": This is a long line of output to test truncation behavior of the shell executor module"); }'); + writeFileSync( + BIGOUT_SCRIPT, + 'for (let i = 0; i < 500; i++) { console.log("Line " + i + ": This is a long line of output to test truncation behavior of the shell executor module"); }', + ); }); afterAll(() => { - try { unlinkSync(SLEEP_SCRIPT); } catch {} - try { unlinkSync(BIGOUT_SCRIPT); } catch {} + try { + unlinkSync(SLEEP_SCRIPT); + } catch {} + try { + unlinkSync(BIGOUT_SCRIPT); + } catch {} }); // ----------------------------------------------------------------------- diff --git a/packages/shell/tests/permissions.test.ts b/packages/shell/tests/permissions.test.ts index dce2087..a808ae7 100644 --- a/packages/shell/tests/permissions.test.ts +++ b/packages/shell/tests/permissions.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import { createPermissionEngine } from '../src/permissions.js'; describe('Shell Permission Engine', () => { @@ -224,8 +224,8 @@ describe('Shell Permission Engine', () => { engine.approve('make test', true); const approvals = engine.listApprovals(); - const buildApproval = approvals.find(a => a.command === 'make build'); - const testApproval = approvals.find(a => a.command === 'make test'); + const buildApproval = approvals.find((a) => a.command === 'make build'); + const testApproval = approvals.find((a) => a.command === 'make test'); expect(buildApproval?.persistent).toBe(false); expect(testApproval?.persistent).toBe(true); @@ -273,10 +273,13 @@ describe('Shell Permission Engine', () => { }); it('restores saved approvals', () => { - const engine = createPermissionEngine([], [ - { command: 'make build', persistent: true, approvedAt: Date.now() }, - { command: 'docker ps', persistent: false, approvedAt: Date.now() }, - ]); + const engine = createPermissionEngine( + [], + [ + { command: 'make build', persistent: true, approvedAt: Date.now() }, + { command: 'docker ps', persistent: false, approvedAt: Date.now() }, + ], + ); expect(engine.evaluate('make build').decision).toBe('allow'); expect(engine.evaluate('docker ps').decision).toBe('allow'); diff --git a/packages/shield/src/engine.ts b/packages/shield/src/engine.ts index d160aff..4d0065e 100644 --- a/packages/shield/src/engine.ts +++ b/packages/shield/src/engine.ts @@ -14,14 +14,14 @@ import { logger } from '@tinyclaw/logger'; import type { + ShieldAction, + ShieldDecision, ShieldEngine, ShieldEvent, - ShieldDecision, - ShieldAction, ThreatEntry, } from '@tinyclaw/types'; -import { parseShieldContent } from './parser.js'; import { matchEvent } from './matcher.js'; +import { parseShieldContent } from './parser.js'; // --------------------------------------------------------------------------- // Constants @@ -71,7 +71,7 @@ export function createShieldEngine(content: string): ShieldEngine { logger.info('Shield engine initialized', { activeThreats: threats.length, - threatIds: threats.map(t => t.id), + threatIds: threats.map((t) => t.id), }); return { diff --git a/packages/shield/src/index.ts b/packages/shield/src/index.ts index 8549936..7a86439 100644 --- a/packages/shield/src/index.ts +++ b/packages/shield/src/index.ts @@ -25,10 +25,9 @@ // Engine export { createShieldEngine } from './engine.js'; - -// Parser -export { parseShieldContent, parseThreatBlock, parseAllThreats } from './parser.js'; +export type { Directive, MatchResult } from './matcher.js'; // Matcher export { matchEvent, parseDirectives } from './matcher.js'; -export type { Directive, MatchResult } from './matcher.js'; +// Parser +export { parseAllThreats, parseShieldContent, parseThreatBlock } from './parser.js'; diff --git a/packages/shield/src/matcher.ts b/packages/shield/src/matcher.ts index 6b7edcf..04481c1 100644 --- a/packages/shield/src/matcher.ts +++ b/packages/shield/src/matcher.ts @@ -21,12 +21,7 @@ * Operators: OR */ -import type { - ThreatEntry, - ShieldEvent, - ShieldAction, - ShieldScope, -} from '@tinyclaw/types'; +import type { ShieldAction, ShieldEvent, ShieldScope, ThreatEntry } from '@tinyclaw/types'; // --------------------------------------------------------------------------- // Types @@ -94,13 +89,13 @@ export function parseDirectives(recommendationAgent: string): Directive[] { /** Maps shield scopes to compatible threat categories. */ const SCOPE_CATEGORY_MAP: Record> = { - 'prompt': new Set(['prompt']), + prompt: new Set(['prompt']), 'skill.install': new Set(['skill', 'supply_chain']), 'skill.execute': new Set(['skill', 'tool']), 'tool.call': new Set(['tool', 'prompt', 'memory', 'vulnerability', 'policy_bypass', 'anomaly']), 'network.egress': new Set(['supply_chain']), 'secrets.read': new Set(['tool', 'vulnerability']), - 'mcp': new Set(['mcp']), + mcp: new Set(['mcp']), }; /** @@ -138,8 +133,10 @@ function evaluateCondition( // Match if the tool name appears in the condition const afterToolCall = condition.slice('tool.call'.length).trim(); - if (afterToolCall.toLowerCase().startsWith(toolNameLower) || - afterToolCall.toLowerCase().includes(toolNameLower)) { + if ( + afterToolCall.toLowerCase().startsWith(toolNameLower) || + afterToolCall.toLowerCase().includes(toolNameLower) + ) { return { matchedOn: 'tool.call', matchValue: event.toolName }; } @@ -149,7 +146,7 @@ function evaluateCondition( // Extract keywords from parenthetical list const parenMatch = condition.match(/\(([^)]+)\)/); if (parenMatch) { - const keywords = parenMatch[1].split(',').map(k => k.trim().toLowerCase()); + const keywords = parenMatch[1].split(',').map((k) => k.trim().toLowerCase()); for (const keyword of keywords) { if (keyword && argsStr.includes(keyword)) { return { matchedOn: 'tool.args', matchValue: keyword }; @@ -202,7 +199,7 @@ function evaluateCondition( if (!event.domain) return null; const domainMatch = condition.match(/outbound request to\s+(.+)/i); if (domainMatch) { - let expected = domainMatch[1].trim().toLowerCase(); + const expected = domainMatch[1].trim().toLowerCase(); const eventDomain = event.domain.toLowerCase(); // Handle OR operator @@ -210,14 +207,14 @@ function evaluateCondition( const alternatives = expected.split(/\s+or\s+/i); for (const alt of alternatives) { const trimmed = alt.trim(); - if (eventDomain === trimmed || eventDomain.endsWith('.' + trimmed)) { + if (eventDomain === trimmed || eventDomain.endsWith(`.${trimmed}`)) { return { matchedOn: 'domain', matchValue: event.domain }; } } return null; } - if (eventDomain === expected || eventDomain.endsWith('.' + expected)) { + if (eventDomain === expected || eventDomain.endsWith(`.${expected}`)) { return { matchedOn: 'domain', matchValue: event.domain }; } } @@ -232,9 +229,7 @@ function evaluateCondition( const expected = pathMatch[1].trim(); // Support wildcard: provider.*.apiKey if (expected.includes('*')) { - const regex = new RegExp( - '^' + expected.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$', - ); + const regex = new RegExp(`^${expected.replace(/\./g, '\\.').replace(/\*/g, '[^.]+')}$`); if (regex.test(event.secretPath)) { return { matchedOn: 'secrets.path', matchValue: event.secretPath }; } @@ -288,8 +283,10 @@ function evaluateCondition( // --- Memory conditions --- if (lc.includes('memory_add') || lc.includes('importance')) { - if (event.toolName?.toLowerCase() === 'memory_add' || - event.toolName?.toLowerCase() === 'heartware_write') { + if ( + event.toolName?.toLowerCase() === 'memory_add' || + event.toolName?.toLowerCase() === 'heartware_write' + ) { const importance = Number(event.toolArgs?.importance ?? 0); if (lc.includes('importance >=')) { @@ -307,8 +304,12 @@ function evaluateCondition( if (lc.includes('content containing instruction-like patterns')) { const content = String(event.toolArgs?.content ?? event.toolArgs?.value ?? ''); const instructionPatterns = [ - /you must/i, /ignore previous/i, /from now on/i, - /your new instructions/i, /override/i, /disregard/i, + /you must/i, + /ignore previous/i, + /from now on/i, + /your new instructions/i, + /override/i, + /disregard/i, ]; for (const pattern of instructionPatterns) { if (pattern.test(content)) { @@ -348,8 +349,10 @@ function evaluateCondition( } if (lc.includes('modify ratelimit config at runtime')) { - if (event.toolName?.toLowerCase().includes('config') && - event.toolArgs?.key === 'security.rateLimit') { + if ( + event.toolName?.toLowerCase().includes('config') && + event.toolArgs?.key === 'security.rateLimit' + ) { return { matchedOn: 'tool.call', matchValue: 'rateLimit modification' }; } return null; @@ -382,10 +385,7 @@ function evaluateCondition( * @param threats - Active threat entries to match against * @returns Array of match results, may be empty */ -export function matchEvent( - event: ShieldEvent, - threats: ThreatEntry[], -): MatchResult[] { +export function matchEvent(event: ShieldEvent, threats: ThreatEntry[]): MatchResult[] { const results: MatchResult[] = []; for (const threat of threats) { diff --git a/packages/shield/src/parser.ts b/packages/shield/src/parser.ts index 9932188..05f10af 100644 --- a/packages/shield/src/parser.ts +++ b/packages/shield/src/parser.ts @@ -11,29 +11,29 @@ * - recommendation_agent multi-line strings */ -import type { - ThreatEntry, - ThreatCategory, - ThreatSeverity, - ShieldAction, -} from '@tinyclaw/types'; +import type { ShieldAction, ThreatCategory, ThreatEntry, ThreatSeverity } from '@tinyclaw/types'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const VALID_CATEGORIES: ReadonlySet = new Set([ - 'prompt', 'tool', 'mcp', 'memory', 'supply_chain', - 'vulnerability', 'fraud', 'policy_bypass', 'anomaly', 'skill', 'other', + 'prompt', + 'tool', + 'mcp', + 'memory', + 'supply_chain', + 'vulnerability', + 'fraud', + 'policy_bypass', + 'anomaly', + 'skill', + 'other', ]); -const VALID_SEVERITIES: ReadonlySet = new Set([ - 'critical', 'high', 'medium', 'low', -]); +const VALID_SEVERITIES: ReadonlySet = new Set(['critical', 'high', 'medium', 'low']); -const VALID_ACTIONS: ReadonlySet = new Set([ - 'block', 'require_approval', 'log', -]); +const VALID_ACTIONS: ReadonlySet = new Set(['block', 'require_approval', 'log']); // --------------------------------------------------------------------------- // Helpers @@ -52,8 +52,10 @@ function extractField(block: string, field: string): string | null { let value = match[1].trim(); // Strip surrounding quotes - if ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { value = value.slice(1, -1); } @@ -119,9 +121,9 @@ export function parseShieldContent(content: string): ThreatEntry[] { // Find all fenced code blocks that contain threat definitions. // Threat blocks are identified by having an "id: THREAT-" field. const codeBlockRegex = /```[\s\S]*?```/g; - let match: RegExpExecArray | null; - while ((match = codeBlockRegex.exec(content)) !== null) { + let match = codeBlockRegex.exec(content); + while (match !== null) { const block = match[0]; // Strip the opening/closing fences @@ -132,6 +134,7 @@ export function parseShieldContent(content: string): ThreatEntry[] { // Only process blocks that look like threat definitions if (!inner.includes('id: THREAT-')) { + match = codeBlockRegex.exec(content); continue; } @@ -139,6 +142,7 @@ export function parseShieldContent(content: string): ThreatEntry[] { if (entry) { threats.push(entry); } + match = codeBlockRegex.exec(content); } return threats; @@ -170,18 +174,20 @@ export function parseThreatBlock(block: string): ThreatEntry | null { if (!VALID_CATEGORIES.has(category)) return null; if (!VALID_SEVERITIES.has(severity)) return null; if (!VALID_ACTIONS.has(action)) return null; - if (isNaN(confidence) || confidence < 0 || confidence > 1) return null; + if (Number.isNaN(confidence) || confidence < 0 || confidence > 1) return null; // Filter out revoked threats if (revoked === 'true') return null; // Filter out expired threats (enforce ISO 8601 format) if (expiresAt && expiresAt !== 'null') { - if (!/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(expiresAt)) { + if ( + !/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(expiresAt) + ) { return null; // Reject non-ISO date strings } const expiryDate = new Date(expiresAt); - if (!isNaN(expiryDate.getTime()) && expiryDate.getTime() < Date.now()) { + if (!Number.isNaN(expiryDate.getTime()) && expiryDate.getTime() < Date.now()) { return null; } } @@ -196,9 +202,9 @@ export function parseThreatBlock(block: string): ThreatEntry | null { title, description, recommendationAgent, - expiresAt: (expiresAt === 'null' || !expiresAt) ? null : expiresAt, + expiresAt: expiresAt === 'null' || !expiresAt ? null : expiresAt, revoked: false, - revokedAt: (revokedAt === 'null' || !revokedAt) ? null : revokedAt, + revokedAt: revokedAt === 'null' || !revokedAt ? null : revokedAt, }; } @@ -215,19 +221,23 @@ export function parseAllThreats(content: string): ThreatEntry[] { const threats: ThreatEntry[] = []; const codeBlockRegex = /```[\s\S]*?```/g; - let match: RegExpExecArray | null; - while ((match = codeBlockRegex.exec(content)) !== null) { + let match = codeBlockRegex.exec(content); + while (match !== null) { const block = match[0]; const inner = block .replace(/^```\w*\s*\n?/, '') .replace(/\n?```$/, '') .trim(); - if (!inner.includes('id: THREAT-')) continue; + if (!inner.includes('id: THREAT-')) { + match = codeBlockRegex.exec(content); + continue; + } const entry = parseThreatBlockRaw(inner); if (entry) threats.push(entry); + match = codeBlockRegex.exec(content); } return threats; @@ -255,7 +265,7 @@ function parseThreatBlockRaw(block: string): ThreatEntry | null { if (!VALID_CATEGORIES.has(category)) return null; if (!VALID_SEVERITIES.has(severity)) return null; if (!VALID_ACTIONS.has(action)) return null; - if (isNaN(confidence) || confidence < 0 || confidence > 1) return null; + if (Number.isNaN(confidence) || confidence < 0 || confidence > 1) return null; return { id, @@ -267,8 +277,8 @@ function parseThreatBlockRaw(block: string): ThreatEntry | null { title, description, recommendationAgent, - expiresAt: (expiresAt === 'null' || !expiresAt) ? null : expiresAt, + expiresAt: expiresAt === 'null' || !expiresAt ? null : expiresAt, revoked: revoked === 'true', - revokedAt: (revokedAt === 'null' || !revokedAt) ? null : revokedAt, + revokedAt: revokedAt === 'null' || !revokedAt ? null : revokedAt, }; } diff --git a/packages/shield/tests/engine.test.ts b/packages/shield/tests/engine.test.ts index 1b73754..3a2f29b 100644 --- a/packages/shield/tests/engine.test.ts +++ b/packages/shield/tests/engine.test.ts @@ -1,9 +1,9 @@ /** * Shield Engine Tests */ -import { describe, it, expect } from 'bun:test'; -import { createShieldEngine } from '../src/engine.js'; +import { describe, expect, it } from 'bun:test'; import type { ShieldEvent } from '@tinyclaw/types'; +import { createShieldEngine } from '../src/engine.js'; // --------------------------------------------------------------------------- // Fixtures @@ -112,7 +112,7 @@ describe('createShieldEngine', () => { const threats1 = engine.getThreats(); const threats2 = engine.getThreats(); expect(threats1).not.toBe(threats2); // different reference - expect(threats1).toEqual(threats2); // same content + expect(threats1).toEqual(threats2); // same content }); }); diff --git a/packages/shield/tests/matcher.test.ts b/packages/shield/tests/matcher.test.ts index bf5a681..3f2881b 100644 --- a/packages/shield/tests/matcher.test.ts +++ b/packages/shield/tests/matcher.test.ts @@ -1,9 +1,9 @@ /** * Shield Matcher Tests */ -import { describe, it, expect } from 'bun:test'; -import { parseDirectives, matchEvent } from '../src/matcher.js'; -import type { ThreatEntry, ShieldEvent } from '@tinyclaw/types'; +import { describe, expect, it } from 'bun:test'; +import type { ShieldEvent, ThreatEntry } from '@tinyclaw/types'; +import { matchEvent, parseDirectives } from '../src/matcher.js'; // --------------------------------------------------------------------------- // Fixtures @@ -15,7 +15,7 @@ function makeThreat(overrides: Partial = {}): ThreatEntry { fingerprint: 'test-fp', category: 'tool', severity: 'high', - confidence: 0.90, + confidence: 0.9, action: 'block', title: 'Test Threat', description: 'Test threat description', @@ -96,7 +96,8 @@ describe('matchEvent — tool.call', () => { it('should match SQL keywords in arguments', () => { const threat = makeThreat({ - recommendationAgent: 'BLOCK: tool.call with arguments containing SQL syntax (DROP, DELETE, UNION, --)', + recommendationAgent: + 'BLOCK: tool.call with arguments containing SQL syntax (DROP, DELETE, UNION, --)', }); const event: ShieldEvent = { @@ -112,7 +113,8 @@ describe('matchEvent — tool.call', () => { it('should not match when no SQL keywords present', () => { const threat = makeThreat({ - recommendationAgent: 'BLOCK: tool.call with arguments containing SQL syntax (DROP, DELETE, UNION)', + recommendationAgent: + 'BLOCK: tool.call with arguments containing SQL syntax (DROP, DELETE, UNION)', }); const event: ShieldEvent = { diff --git a/packages/shield/tests/parser.test.ts b/packages/shield/tests/parser.test.ts index 06446e5..ab361e6 100644 --- a/packages/shield/tests/parser.test.ts +++ b/packages/shield/tests/parser.test.ts @@ -1,8 +1,8 @@ /** * Shield Parser Tests */ -import { describe, it, expect } from 'bun:test'; -import { parseShieldContent, parseThreatBlock, parseAllThreats } from '../src/parser.js'; +import { describe, expect, it } from 'bun:test'; +import { parseAllThreats, parseShieldContent, parseThreatBlock } from '../src/parser.js'; // --------------------------------------------------------------------------- // Fixtures @@ -92,13 +92,13 @@ describe('parseThreatBlock', () => { it('should parse a valid threat block', () => { const result = parseThreatBlock(MINIMAL_THREAT); expect(result).not.toBeNull(); - expect(result!.id).toBe('THREAT-001'); - expect(result!.fingerprint).toBe('abc123'); - expect(result!.category).toBe('tool'); - expect(result!.severity).toBe('high'); - expect(result!.confidence).toBe(0.90); - expect(result!.action).toBe('block'); - expect(result!.title).toBe('SQL Injection via Tool'); + expect(result?.id).toBe('THREAT-001'); + expect(result?.fingerprint).toBe('abc123'); + expect(result?.category).toBe('tool'); + expect(result?.severity).toBe('high'); + expect(result?.confidence).toBe(0.9); + expect(result?.action).toBe('block'); + expect(result?.title).toBe('SQL Injection via Tool'); }); it('should return null for missing id', () => { @@ -200,14 +200,14 @@ expires_at: 2099-12-31T23:59:59Z `; const result = parseThreatBlock(block); expect(result).not.toBeNull(); - expect(result!.expiresAt).toBe('2099-12-31T23:59:59Z'); + expect(result?.expiresAt).toBe('2099-12-31T23:59:59Z'); }); it('should parse recommendation_agent multiline content', () => { const result = parseThreatBlock(MINIMAL_THREAT); expect(result).not.toBeNull(); - expect(result!.recommendationAgent).toContain('BLOCK:'); - expect(result!.recommendationAgent).toContain('tool.call'); + expect(result?.recommendationAgent).toContain('BLOCK:'); + expect(result?.recommendationAgent).toContain('tool.call'); }); }); @@ -226,7 +226,7 @@ describe('parseShieldContent', () => { it('should filter out revoked threats', () => { const threats = parseShieldContent(FULL_SHIELD_MD); - const ids = threats.map(t => t.id); + const ids = threats.map((t) => t.id); expect(ids).not.toContain('THREAT-003'); }); @@ -252,9 +252,9 @@ describe('parseAllThreats', () => { it('should include revoked threats', () => { const all = parseAllThreats(FULL_SHIELD_MD); expect(all.length).toBe(3); - const revoked = all.find(t => t.id === 'THREAT-003'); + const revoked = all.find((t) => t.id === 'THREAT-003'); expect(revoked).toBeDefined(); - expect(revoked!.revoked).toBe(true); + expect(revoked?.revoked).toBe(true); }); it('should return empty array for empty content', () => { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e924c04..d0acb02 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -30,8 +30,16 @@ export interface LLMResponse { } export interface StreamEvent { - type: 'text' | 'tool_start' | 'tool_result' | 'done' | 'error' - | 'delegation_start' | 'delegation_complete' | 'background_start' | 'background_update'; + type: + | 'text' + | 'tool_start' + | 'tool_result' + | 'done' + | 'error' + | 'delegation_start' + | 'delegation_complete' + | 'background_start' + | 'background_update'; content?: string; tool?: string; result?: string; @@ -235,7 +243,12 @@ export interface Database { // Background tasks saveBackgroundTask(record: BackgroundTask): void; - updateBackgroundTask(id: string, status: string, result: string | null, completedAt: number | null): void; + updateBackgroundTask( + id: string, + status: string, + result: string | null, + completedAt: number | null, + ): void; getUndeliveredTasks(userId: string): BackgroundTask[]; getUserBackgroundTasks(userId: string): BackgroundTask[]; getBackgroundTask(id: string): BackgroundTask | null; @@ -248,9 +261,18 @@ export interface Database { getEpisodicEvents(userId: string, limit?: number): EpisodicRecord[]; updateEpisodicEvent(id: string, updates: Partial): void; deleteEpisodicEvents(ids: string[]): void; - searchEpisodicFTS(query: string, userId: string, limit?: number): Array<{ id: string; rank: number }>; + searchEpisodicFTS( + query: string, + userId: string, + limit?: number, + ): Array<{ id: string; rank: number }>; decayEpisodicImportance(userId: string, olderThanDays: number, decayFactor: number): number; - pruneEpisodicEvents(userId: string, maxImportance: number, maxAccessCount: number, olderThanMs: number): number; + pruneEpisodicEvents( + userId: string, + maxImportance: number, + maxAccessCount: number, + olderThanMs: number, + ): number; // Task metrics (v3) saveTaskMetric(record: TaskMetricRecord): void; @@ -357,12 +379,15 @@ export interface MemorySearchResult { export interface MemoryEngine { /** Store an episodic event. */ - recordEvent(userId: string, event: { - type: EpisodicEventType; - content: string; - outcome?: string; - importance?: number; - }): string; // returns event id + recordEvent( + userId: string, + event: { + type: EpisodicEventType; + content: string; + outcome?: string; + importance?: number; + }, + ): string; // returns event id /** Search memory using hybrid scoring: FTS5 BM25 + temporal decay + importance. */ search(userId: string, query: string, limit?: number): MemorySearchResult[]; @@ -500,7 +525,9 @@ export interface ConfigManagerInterface { /** Watch a specific key for changes */ onDidChange(key: string, callback: (newValue?: V, oldValue?: V) => void): () => void; /** Watch the entire config for any change */ - onDidAnyChange(callback: (newValue?: Record, oldValue?: Record) => void): () => void; + onDidAnyChange( + callback: (newValue?: Record, oldValue?: Record) => void, + ): () => void; /** Close the underlying config engine */ close(): void; } @@ -597,10 +624,7 @@ export interface ChannelPlugin extends PluginMeta { * Return pairing tools that the agent can invoke to configure this channel. * These tools are merged into AgentContext.tools before the agent loop starts. */ - getPairingTools?( - secrets: SecretsManagerInterface, - configManager: ConfigManagerInterface, - ): Tool[]; + getPairingTools?(secrets: SecretsManagerInterface, configManager: ConfigManagerInterface): Tool[]; /** * Send an outbound message to a user on this channel. * Optional — channels that support proactive messaging implement this. @@ -621,10 +645,7 @@ export interface ProviderPlugin extends PluginMeta { /** Create and return an initialized Provider instance. */ createProvider(secrets: SecretsManagerInterface): Promise; /** Optional pairing tools for conversational setup (API key, model config). */ - getPairingTools?( - secrets: SecretsManagerInterface, - configManager: ConfigManagerInterface, - ): Tool[]; + getPairingTools?(secrets: SecretsManagerInterface, configManager: ConfigManagerInterface): Tool[]; } /** A tools plugin contributes additional agent tools. */ @@ -814,10 +835,7 @@ export interface OutboundGateway { * Register a channel sender for a given userId prefix. * Called during boot after channel plugins are loaded. */ - register( - prefix: string, - sender: ChannelSender, - ): void; + register(prefix: string, sender: ChannelSender): void; /** * Unregister a channel sender (e.g. during shutdown). @@ -828,18 +846,13 @@ export interface OutboundGateway { * Send a message to a specific user. * Resolves the userId prefix → channel sender → delivers. */ - send( - userId: string, - message: OutboundMessage, - ): Promise; + send(userId: string, message: OutboundMessage): Promise; /** * Broadcast a message to all registered channels. * Each channel decides how to handle the broadcast (e.g. to all connected users). */ - broadcast( - message: OutboundMessage, - ): Promise; + broadcast(message: OutboundMessage): Promise; /** * Get all registered channel prefixes. @@ -880,15 +893,15 @@ export interface OutboundDeliveryResult { /** Categories of nudge notifications the agent can send proactively. */ export type NudgeCategory = - | 'task_complete' // A background task finished - | 'task_failed' // A background task failed - | 'reminder' // Scheduled reminder from the agent - | 'check_in' // Periodic check-in / wellness nudge - | 'insight' // Agent-initiated insight or suggestion - | 'system' // System-level notification (updates, warnings) - | 'software_update' // A new Tiny Claw version is available - | 'agent_initiated' // Free-form agent-initiated outreach - | 'companion'; // AI-generated companion nudge (mood roulette) + | 'task_complete' // A background task finished + | 'task_failed' // A background task failed + | 'reminder' // Scheduled reminder from the agent + | 'check_in' // Periodic check-in / wellness nudge + | 'insight' // Agent-initiated insight or suggestion + | 'system' // System-level notification (updates, warnings) + | 'software_update' // A new Tiny Claw version is available + | 'agent_initiated' // Free-form agent-initiated outreach + | 'companion'; // AI-generated companion nudge (mood roulette) /** A nudge queued for delivery. */ export interface Nudge { diff --git a/plugins/channel/plugin-channel-discord/src/index.ts b/plugins/channel/plugin-channel-discord/src/index.ts index e9c0a50..63e9546 100644 --- a/plugins/channel/plugin-channel-discord/src/index.ts +++ b/plugins/channel/plugin-channel-discord/src/index.ts @@ -15,26 +15,26 @@ * Prefixed to prevent collisions with web UI user IDs. */ -import { - Client, - GatewayIntentBits, - Partials, - Events, - type Message as DiscordMessage, -} from 'discord.js'; import { logger } from '@tinyclaw/logger'; import type { ChannelPlugin, - PluginRuntimeContext, - Tool, - SecretsManagerInterface, ConfigManagerInterface, OutboundMessage, + PluginRuntimeContext, + SecretsManagerInterface, + Tool, } from '@tinyclaw/types'; +import { + Client, + type Message as DiscordMessage, + Events, + GatewayIntentBits, + Partials, +} from 'discord.js'; import { createDiscordPairingTools, - DISCORD_TOKEN_SECRET_KEY, DISCORD_ENABLED_CONFIG_KEY, + DISCORD_TOKEN_SECRET_KEY, } from './pairing.js'; let client: Client | null = null; @@ -47,10 +47,7 @@ const discordPlugin: ChannelPlugin = { version: '0.1.0', channelPrefix: 'discord', - getPairingTools( - secrets: SecretsManagerInterface, - configManager: ConfigManagerInterface, - ): Tool[] { + getPairingTools(secrets: SecretsManagerInterface, configManager: ConfigManagerInterface): Tool[] { return createDiscordPairingTools(secrets, configManager); }, @@ -86,17 +83,13 @@ const discordPlugin: ChannelPlugin = { if (msg.author.bot) return; const isDM = msg.channel.isDMBased(); - const isMention = client?.user - ? msg.mentions.users.has(client.user.id) - : false; + const isMention = client?.user ? msg.mentions.users.has(client.user.id) : false; // Only respond to DMs or @mentions if (!isDM && !isMention) return; // Strip @mention tokens from guild messages - const rawContent = msg.content - .replace(/<@!?[\d]+>/g, '') - .trim(); + const rawContent = msg.content.replace(/<@!?[\d]+>/g, '').trim(); if (!rawContent) return; diff --git a/plugins/channel/plugin-channel-discord/src/pairing.ts b/plugins/channel/plugin-channel-discord/src/pairing.ts index 59ba141..2b5588d 100644 --- a/plugins/channel/plugin-channel-discord/src/pairing.ts +++ b/plugins/channel/plugin-channel-discord/src/pairing.ts @@ -10,7 +10,7 @@ * can invoke them conversationally when a user asks to connect Discord. */ -import type { Tool, SecretsManagerInterface, ConfigManagerInterface } from '@tinyclaw/types'; +import type { ConfigManagerInterface, SecretsManagerInterface, Tool } from '@tinyclaw/types'; import { buildChannelKeyName } from '@tinyclaw/types'; /** Secret key for the Discord bot token. */ diff --git a/plugins/channel/plugin-channel-discord/tests/index.test.ts b/plugins/channel/plugin-channel-discord/tests/index.test.ts index d759a1d..2682d45 100644 --- a/plugins/channel/plugin-channel-discord/tests/index.test.ts +++ b/plugins/channel/plugin-channel-discord/tests/index.test.ts @@ -5,7 +5,7 @@ * and the start/stop lifecycle guards. */ -import { describe, test, expect } from 'bun:test'; +import { describe, expect, test } from 'bun:test'; import discordPlugin, { splitIntoChunks } from '../src/index.js'; // --------------------------------------------------------------------------- @@ -65,7 +65,7 @@ describe('getPairingTools', () => { close: () => {}, }; - const tools = discordPlugin.getPairingTools!(mockSecrets as any, mockConfig as any); + const tools = discordPlugin.getPairingTools?.(mockSecrets as any, mockConfig as any); expect(tools).toHaveLength(2); expect(tools.map((t) => t.name)).toEqual(['discord_pair', 'discord_unpair']); }); diff --git a/plugins/channel/plugin-channel-discord/tests/pairing.test.ts b/plugins/channel/plugin-channel-discord/tests/pairing.test.ts index ffd01ca..884fcd5 100644 --- a/plugins/channel/plugin-channel-discord/tests/pairing.test.ts +++ b/plugins/channel/plugin-channel-discord/tests/pairing.test.ts @@ -5,13 +5,13 @@ * error handling, and the unpair cleanup flow. */ -import { describe, test, expect, beforeEach } from 'bun:test'; -import type { Tool, SecretsManagerInterface, ConfigManagerInterface } from '@tinyclaw/types'; +import { beforeEach, describe, expect, test } from 'bun:test'; +import type { ConfigManagerInterface, SecretsManagerInterface, Tool } from '@tinyclaw/types'; import { createDiscordPairingTools, - DISCORD_TOKEN_SECRET_KEY, DISCORD_ENABLED_CONFIG_KEY, DISCORD_PLUGIN_ID, + DISCORD_TOKEN_SECRET_KEY, } from '../src/pairing.js'; // --------------------------------------------------------------------------- diff --git a/plugins/channel/plugin-channel-friends/src/index.ts b/plugins/channel/plugin-channel-friends/src/index.ts index cd3dfb8..d6611a6 100644 --- a/plugins/channel/plugin-channel-friends/src/index.ts +++ b/plugins/channel/plugin-channel-friends/src/index.ts @@ -20,26 +20,26 @@ * userId format: "friend:" */ -import { readFileSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { logger } from '@tinyclaw/logger'; import type { ChannelPlugin, - PluginRuntimeContext, - Tool, - SecretsManagerInterface, ConfigManagerInterface, OutboundMessage, + PluginRuntimeContext, + SecretsManagerInterface, + Tool, } from '@tinyclaw/types'; +import { createFriendsServer } from './server.js'; import { InviteStore } from './store.js'; import { createFriendsTools, FRIENDS_ENABLED_CONFIG_KEY, - FRIENDS_PORT_CONFIG_KEY, FRIENDS_PLUGIN_ID, + FRIENDS_PORT_CONFIG_KEY, } from './tools.js'; -import { createFriendsServer } from './server.js'; // Resolve the directory of this source file for static asset paths const __filename = fileURLToPath(import.meta.url); @@ -91,7 +91,8 @@ const friendsPlugin: ChannelPlugin = { } const port = context.configManager.get(FRIENDS_PORT_CONFIG_KEY) || 3001; - const host = process.env.HOST || context.configManager.get('friends.host') || '127.0.0.1'; + const host = + process.env.HOST || context.configManager.get('friends.host') || '127.0.0.1'; const chatHtml = loadChatHtml(); friendsServer = createFriendsServer({ diff --git a/plugins/channel/plugin-channel-friends/src/server.ts b/plugins/channel/plugin-channel-friends/src/server.ts index 7cba607..284e7f0 100644 --- a/plugins/channel/plugin-channel-friends/src/server.ts +++ b/plugins/channel/plugin-channel-friends/src/server.ts @@ -13,7 +13,7 @@ */ import { logger } from '@tinyclaw/logger'; -import type { InviteStore, FriendUser } from './store.js'; +import type { FriendUser, InviteStore } from './store.js'; const COOKIE_NAME = 'tc_friend_session'; const COOKIE_MAX_AGE = 365 * 24 * 60 * 60; // 1 year in seconds @@ -93,7 +93,7 @@ function authenticateFriend(request: Request, store: InviteStore): FriendUser | export function createFriendsServer(config: FriendsServerConfig) { const { port, host, store, chatHtml, onMessage, onMessageStream } = config; - const secure = config.secure ?? (process.env.NODE_ENV === 'production'); + const secure = config.secure ?? process.env.NODE_ENV === 'production'; const textEncoder = new TextEncoder(); const pushClients = new Map>(); @@ -228,7 +228,7 @@ export function createFriendsServer(config: FriendsServerConfig) { if (!pushClients.has(username)) { pushClients.set(username, new Set()); } - pushClients.get(username)!.add(pushClient); + pushClients.get(username)?.add(pushClient); // Heartbeat to keep connection alive const heartbeat = setInterval(() => { @@ -305,10 +305,7 @@ export function createFriendsServer(config: FriendsServerConfig) { const send = (payload: unknown) => { if (isClosed) return; try { - const data = - typeof payload === 'string' - ? payload - : JSON.stringify(payload); + const data = typeof payload === 'string' ? payload : JSON.stringify(payload); controller.enqueue(textEncoder.encode(`data: ${data}\n\n`)); if ( typeof payload === 'object' && @@ -365,7 +362,9 @@ export function createFriendsServer(config: FriendsServerConfig) { const responseText = await onMessage(message, userId); return jsonResponse({ content: responseText }); } catch (error) { - logger.error(`Friends chat onMessage error for userId=${userId}: ${(error as Error)?.message}`); + logger.error( + `Friends chat onMessage error for userId=${userId}: ${(error as Error)?.message}`, + ); return jsonResponse({ error: 'Internal server error' }, 500); } } @@ -411,7 +410,7 @@ export function createFriendsServer(config: FriendsServerConfig) { clients.delete(dc); } - return clients.size > 0 || dead.length < (clients.size + dead.length); + return clients.size > 0 || dead.length < clients.size + dead.length; }, getPort() { diff --git a/plugins/channel/plugin-channel-friends/src/store.ts b/plugins/channel/plugin-channel-friends/src/store.ts index c1377cd..a291808 100644 --- a/plugins/channel/plugin-channel-friends/src/store.ts +++ b/plugins/channel/plugin-channel-friends/src/store.ts @@ -169,10 +169,10 @@ export class InviteStore { const newCode = generateInviteCode(); // New code, clear session so they must re-authenticate - this.db.run( - `UPDATE friends SET invite_code = ?, session_token = NULL WHERE username = ?`, - [newCode, sanitized], - ); + this.db.run(`UPDATE friends SET invite_code = ?, session_token = NULL WHERE username = ?`, [ + newCode, + sanitized, + ]); return newCode; } @@ -180,20 +180,17 @@ export class InviteStore { /** Update a friend's nickname. */ updateNickname(username: string, newNickname: string): boolean { const sanitized = username.toLowerCase().replace(/[^a-z0-9_]/g, '_'); - const result = this.db.run( - `UPDATE friends SET nickname = ? WHERE username = ?`, - [newNickname, sanitized], - ); + const result = this.db.run(`UPDATE friends SET nickname = ? WHERE username = ?`, [ + newNickname, + sanitized, + ]); return result.changes > 0; } /** Touch last_seen timestamp. */ touchLastSeen(username: string): void { const sanitized = username.toLowerCase().replace(/[^a-z0-9_]/g, '_'); - this.db.run( - `UPDATE friends SET last_seen = ? WHERE username = ?`, - [Date.now(), sanitized], - ); + this.db.run(`UPDATE friends SET last_seen = ? WHERE username = ?`, [Date.now(), sanitized]); } /** Revoke a friend's access — clears session and invite code. */ @@ -221,9 +218,7 @@ export class InviteStore { /** Check if a username already exists. */ exists(username: string): boolean { const sanitized = username.toLowerCase().replace(/[^a-z0-9_]/g, '_'); - const row = this.db - .query(`SELECT 1 FROM friends WHERE username = ?`) - .get(sanitized); + const row = this.db.query(`SELECT 1 FROM friends WHERE username = ?`).get(sanitized); return row !== null; } diff --git a/plugins/channel/plugin-channel-friends/src/tools.ts b/plugins/channel/plugin-channel-friends/src/tools.ts index d68d4b5..e4f9876 100644 --- a/plugins/channel/plugin-channel-friends/src/tools.ts +++ b/plugins/channel/plugin-channel-friends/src/tools.ts @@ -8,8 +8,8 @@ * All tools are owner-only (checked by the agent loop's authority system). */ -import type { Tool, ConfigManagerInterface } from '@tinyclaw/types'; -import { InviteStore } from './store.js'; +import type { ConfigManagerInterface, Tool } from '@tinyclaw/types'; +import type { InviteStore } from './store.js'; /** Config key for the enabled flag. */ export const FRIENDS_ENABLED_CONFIG_KEY = 'channels.friends.enabled'; @@ -51,7 +51,7 @@ export function createFriendsTools( required: ['username'], }, async execute(args: Record): Promise { - const username = (args.username as string || '').trim(); + const username = ((args.username as string) || '').trim(); if (!username) { return 'Error: username is required.'; } @@ -65,7 +65,7 @@ export function createFriendsTools( return `Error: a friend with username "${sanitized}" already exists. Use friends_chat_reinvite to generate a new invite code for them.`; } - const nickname = (args.nickname as string || '').trim() || undefined; + const nickname = ((args.nickname as string) || '').trim() || undefined; const friend = store.createFriend(sanitized, nickname); const baseUrl = configManager.get(FRIENDS_BASE_URL_CONFIG_KEY) || ''; @@ -102,7 +102,7 @@ export function createFriendsTools( required: ['username'], }, async execute(args: Record): Promise { - const username = (args.username as string || '').trim(); + const username = ((args.username as string) || '').trim(); if (!username) { return 'Error: username is required.'; } @@ -132,7 +132,7 @@ export function createFriendsTools( { name: 'friends_chat_revoke', description: - 'Revoke a friend\'s access to the Friends Web Chat. ' + + "Revoke a friend's access to the Friends Web Chat. " + 'Their session and any pending invite code are invalidated immediately. ' + 'To restore access later, use friends_chat_reinvite.', parameters: { @@ -146,7 +146,7 @@ export function createFriendsTools( required: ['username'], }, async execute(args: Record): Promise { - const username = (args.username as string || '').trim(); + const username = ((args.username as string) || '').trim(); if (!username) { return 'Error: username is required.'; } @@ -185,14 +185,13 @@ export function createFriendsTools( } const lines = friends.map((f) => { - const status = f.sessionToken - ? 'active' - : f.inviteCode - ? 'invite pending' - : 'revoked'; + const status = f.sessionToken ? 'active' : f.inviteCode ? 'invite pending' : 'revoked'; const lastSeenDate = new Date(f.lastSeen); - const lastSeen = f.lastSeen && !isNaN(lastSeenDate.getTime()) ? lastSeenDate.toLocaleString() : 'Unknown'; + const lastSeen = + f.lastSeen && !Number.isNaN(lastSeenDate.getTime()) + ? lastSeenDate.toLocaleString() + : 'Unknown'; return `- **${f.nickname}** (@${f.username}) — ${status}, last seen: ${lastSeen}`; }); diff --git a/plugins/provider/plugin-provider-openai/src/index.ts b/plugins/provider/plugin-provider-openai/src/index.ts index 97ec692..649262f 100644 --- a/plugins/provider/plugin-provider-openai/src/index.ts +++ b/plugins/provider/plugin-provider-openai/src/index.ts @@ -13,13 +13,13 @@ */ import type { + ConfigManagerInterface, ProviderPlugin, SecretsManagerInterface, - ConfigManagerInterface, Tool, } from '@tinyclaw/types'; -import { createOpenAIProvider } from './provider.js'; import { createOpenAIPairingTools } from './pairing.js'; +import { createOpenAIProvider } from './provider.js'; const openaiPlugin: ProviderPlugin = { id: '@tinyclaw/plugin-provider-openai', @@ -32,10 +32,7 @@ const openaiPlugin: ProviderPlugin = { return createOpenAIProvider({ secrets }); }, - getPairingTools( - secrets: SecretsManagerInterface, - configManager: ConfigManagerInterface, - ): Tool[] { + getPairingTools(secrets: SecretsManagerInterface, configManager: ConfigManagerInterface): Tool[] { return createOpenAIPairingTools(secrets, configManager); }, }; diff --git a/plugins/provider/plugin-provider-openai/src/pairing.ts b/plugins/provider/plugin-provider-openai/src/pairing.ts index bf349a9..cb13d42 100644 --- a/plugins/provider/plugin-provider-openai/src/pairing.ts +++ b/plugins/provider/plugin-provider-openai/src/pairing.ts @@ -10,7 +10,7 @@ * can invoke them conversationally when a user asks to connect OpenAI. */ -import type { Tool, SecretsManagerInterface, ConfigManagerInterface } from '@tinyclaw/types'; +import type { ConfigManagerInterface, SecretsManagerInterface, Tool } from '@tinyclaw/types'; /** Secret key for the OpenAI API key. */ export const OPENAI_SECRET_KEY = 'provider.openai.apiKey'; diff --git a/plugins/provider/plugin-provider-openai/src/provider.ts b/plugins/provider/plugin-provider-openai/src/provider.ts index 964166b..19de245 100644 --- a/plugins/provider/plugin-provider-openai/src/provider.ts +++ b/plugins/provider/plugin-provider-openai/src/provider.ts @@ -13,12 +13,12 @@ import { logger } from '@tinyclaw/logger'; import type { - Provider, - Message, LLMResponse, + Message, + Provider, + SecretsManagerInterface, Tool, ToolCall, - SecretsManagerInterface, } from '@tinyclaw/types'; // --------------------------------------------------------------------------- @@ -76,9 +76,10 @@ function toOpenAIMessages(messages: Message[]): OpenAIMessage[] { }); } -function toOpenAITools( - tools: Tool[], -): { type: 'function'; function: { name: string; description: string; parameters: Record } }[] { +function toOpenAITools(tools: Tool[]): { + type: 'function'; + function: { name: string; description: string; parameters: Record }; +}[] { return tools.map((t) => ({ type: 'function' as const, function: { @@ -117,7 +118,7 @@ export function createOpenAIProvider(config: OpenAIProviderConfig): Provider { if (!apiKey) { throw new Error( 'No API key available for OpenAI. ' + - 'Store one with: store_secret key="provider.openai.apiKey" value="sk-..."', + 'Store one with: store_secret key="provider.openai.apiKey" value="sk-..."', ); } @@ -133,7 +134,7 @@ export function createOpenAIProvider(config: OpenAIProviderConfig): Provider { const response = await fetch(`${baseUrl}/v1/chat/completions`, { method: 'POST', headers: { - 'Authorization': `Bearer ${apiKey}`, + Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), @@ -182,7 +183,7 @@ export function createOpenAIProvider(config: OpenAIProviderConfig): Provider { const response = await fetch(`${baseUrl}/v1/models`, { headers: { - 'Authorization': `Bearer ${apiKey}`, + Authorization: `Bearer ${apiKey}`, }, }); return response.ok; diff --git a/src/cli/src/commands/backup.ts b/src/cli/src/commands/backup.ts index 6a29318..d24ee2b 100644 --- a/src/cli/src/commands/backup.ts +++ b/src/cli/src/commands/backup.ts @@ -22,16 +22,13 @@ * audit/ — audit logs */ -import { join, resolve, basename, sep } from 'path'; -import { homedir } from 'os'; -import { existsSync, createReadStream, createWriteStream } from 'fs'; -import { readdir, stat, readFile, mkdir } from 'fs/promises'; -import { createGzip, createGunzip } from 'zlib'; -import { pipeline } from 'stream/promises'; +import { mkdir, readdir, readFile, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename, join, resolve, sep } from 'node:path'; import * as p from '@clack/prompts'; -import { SecretsManager } from '@tinyclaw/secrets'; -import { parseSeed, generateSoul } from '@tinyclaw/heartware'; +import { generateSoul, parseSeed } from '@tinyclaw/heartware'; import { setLogMode } from '@tinyclaw/logger'; +import { SecretsManager } from '@tinyclaw/secrets'; import { showBanner } from '../ui/banner.js'; import { theme } from '../ui/theme.js'; @@ -100,7 +97,7 @@ async function getSoulName(dataDir: string): Promise { const TAR_BLOCK = 512; function encodeOctal(value: number, length: number): string { - return value.toString(8).padStart(length - 1, '0') + '\0'; + return `${value.toString(8).padStart(length - 1, '0')}\0`; } function createTarHeader(name: string, size: number, mtime: number): Buffer { @@ -132,13 +129,13 @@ function createTarHeader(name: string, size: number, mtime: number): Buffer { for (let i = 0; i < TAR_BLOCK; i++) { checksum += header[i]; } - header.write(encodeOctal(checksum, 7) + ' ', 148, 8, 'utf-8'); + header.write(`${encodeOctal(checksum, 7)} `, 148, 8, 'utf-8'); return header; } function createTarDirHeader(name: string, mtime: number): Buffer { - const dirName = name.endsWith('/') ? name : name + '/'; + const dirName = name.endsWith('/') ? name : `${name}/`; const header = Buffer.alloc(TAR_BLOCK); header.write(dirName, 0, Math.min(dirName.length, 100), 'utf-8'); @@ -157,7 +154,7 @@ function createTarDirHeader(name: string, mtime: number): Buffer { for (let i = 0; i < TAR_BLOCK; i++) { checksum += header[i]; } - header.write(encodeOctal(checksum, 7) + ' ', 148, 8, 'utf-8'); + header.write(`${encodeOctal(checksum, 7)} `, 148, 8, 'utf-8'); return header; } @@ -222,8 +219,8 @@ async function exportBackup(args: string[]): Promise { const dataDir = resolveDataDir(); if (!(await dirExists(dataDir))) { - p.log.error('Nothing to export — Tiny Claw hasn\'t been set up yet.'); - p.outro('Run ' + theme.cmd('tinyclaw setup') + ' to get started.'); + p.log.error("Nothing to export — Tiny Claw hasn't been set up yet."); + p.outro(`Run ${theme.cmd('tinyclaw setup')} to get started.`); return; } @@ -257,7 +254,8 @@ async function exportBackup(args: string[]): Promise { const exportSpinner = p.spinner(); exportSpinner.start('Collecting Tiny Claw data'); - const allFiles: { relativePath: string; absolutePath: string; size: number; mtime: number }[] = []; + const allFiles: { relativePath: string; absolutePath: string; size: number; mtime: number }[] = + []; for (const dir of EXPORTABLE_DIRS) { const dirPath = join(dataDir, dir); @@ -282,7 +280,7 @@ async function exportBackup(args: string[]): Promise { } // Build manifest - const { hostname } = await import('os'); + const { hostname } = await import('node:os'); const manifest: BackupManifest = { version: MANIFEST_VERSION, createdAt: new Date().toISOString(), @@ -343,9 +341,9 @@ async function exportBackup(args: string[]): Promise { // Gzip and write exportSpinner.message('Compressing archive'); - const { gzipSync } = await import('zlib'); + const { gzipSync } = await import('node:zlib'); const compressed = gzipSync(tarBuffer, { level: 9 }); - const { writeFile } = await import('fs/promises'); + const { writeFile } = await import('node:fs/promises'); await writeFile(outputPath, compressed); const sizeMB = (compressed.length / (1024 * 1024)).toFixed(2); @@ -361,12 +359,14 @@ async function exportBackup(args: string[]): Promise { if (secretKeys.length > 0) { summary.push(''); - summary.push(` ${theme.label('Secret keys')} ${theme.dim('(names only — values are NOT exported)')}`); + summary.push( + ` ${theme.label('Secret keys')} ${theme.dim('(names only — values are NOT exported)')}`, + ); for (const key of secretKeys) { summary.push(` ${theme.dim('•')} ${key}`); } summary.push(''); - summary.push(theme.warn(' ⚠ You\'ll need to re-enter these secret values when importing')); + summary.push(theme.warn(" ⚠ You'll need to re-enter these secret values when importing")); summary.push(theme.dim(' on another machine. Secrets are machine-bound and cannot be')); summary.push(theme.dim(' transferred.')); } else { @@ -385,8 +385,18 @@ async function exportBackup(args: string[]): Promise { * Parse a tar buffer and extract entries. * @internal Exported for testing only. */ -export function parseTar(buffer: Buffer): { name: string; type: 'file' | 'directory' | 'symlink' | 'hardlink'; data: Buffer; linkname?: string }[] { - const entries: { name: string; type: 'file' | 'directory' | 'symlink' | 'hardlink'; data: Buffer; linkname?: string }[] = []; +export function parseTar(buffer: Buffer): { + name: string; + type: 'file' | 'directory' | 'symlink' | 'hardlink'; + data: Buffer; + linkname?: string; +}[] { + const entries: { + name: string; + type: 'file' | 'directory' | 'symlink' | 'hardlink'; + data: Buffer; + linkname?: string; + }[] = []; let offset = 0; while (offset + TAR_BLOCK <= buffer.length) { @@ -395,7 +405,10 @@ export function parseTar(buffer: Buffer): { name: string; type: 'file' | 'direct // Check for end-of-archive (all zeros) let allZero = true; for (let i = 0; i < TAR_BLOCK; i++) { - if (header[i] !== 0) { allZero = false; break; } + if (header[i] !== 0) { + allZero = false; + break; + } } if (allZero) break; @@ -448,7 +461,7 @@ async function importBackup(args: string[]): Promise { const importSpinner = p.spinner(); importSpinner.start('Reading archive'); - const { gunzipSync } = await import('zlib'); + const { gunzipSync } = await import('node:zlib'); const compressed = await readFile(resolvedPath); let tarBuffer: Buffer; @@ -467,7 +480,9 @@ async function importBackup(args: string[]): Promise { const manifestEntry = entries.find((e) => e.name === 'manifest.json'); if (!manifestEntry || manifestEntry.type !== 'file') { importSpinner.stop(theme.error('Failed')); - p.log.error('Invalid archive — missing manifest.json. This doesn\'t appear to be a Tiny Claw backup.'); + p.log.error( + "Invalid archive — missing manifest.json. This doesn't appear to be a Tiny Claw backup.", + ); return; } @@ -498,12 +513,16 @@ async function importBackup(args: string[]): Promise { if (manifest.soulName) { info.push(` ${theme.label('Soul')} ${manifest.soulName}`); } - info.push(` ${theme.label('From')} ${manifest.machine.hostname} (${manifest.machine.platform})`); + info.push( + ` ${theme.label('From')} ${manifest.machine.hostname} (${manifest.machine.platform})`, + ); info.push(` ${theme.label('Files')} ${manifest.files.length}`); if (manifest.secretKeys.length > 0) { info.push(''); - info.push(` ${theme.label('Secrets to re-enter')} ${theme.dim(`(${manifest.secretKeys.length} keys)`)}`); + info.push( + ` ${theme.label('Secrets to re-enter')} ${theme.dim(`(${manifest.secretKeys.length} keys)`)}`, + ); for (const key of manifest.secretKeys) { info.push(` ${theme.dim('•')} ${key}`); } @@ -514,8 +533,10 @@ async function importBackup(args: string[]): Promise { // Warn if existing data will be overwritten if (dataExists) { p.log.warn( - theme.warn('⚠ Existing Tiny Claw data found at ') + theme.dim(dataDir) + '\n' + - theme.warn(' Importing will overwrite your current data.') + theme.warn('⚠ Existing Tiny Claw data found at ') + + theme.dim(dataDir) + + '\n' + + theme.warn(' Importing will overwrite your current data.'), ); } @@ -559,9 +580,9 @@ async function importBackup(args: string[]): Promise { await mkdir(targetPath, { recursive: true }); } else { // Ensure parent directory exists - const { dirname } = await import('path'); + const { dirname } = await import('node:path'); await mkdir(dirname(targetPath), { recursive: true }); - const { writeFile } = await import('fs/promises'); + const { writeFile } = await import('node:fs/promises'); await writeFile(targetPath, entry.data); extracted++; } @@ -583,10 +604,11 @@ async function importBackup(args: string[]): Promise { if (manifest.secretKeys.length > 0) { p.log.step(''); p.log.info( - theme.label('Secret Re-entry') + '\n\n' + - ' Your previous install had secrets that need to be re-entered.\n' + - ' Secrets are machine-bound and cannot be transferred between machines.\n' + - ' Each value will be encrypted with this machine\'s identity.\n' + theme.label('Secret Re-entry') + + '\n\n' + + ' Your previous install had secrets that need to be re-entered.\n' + + ' Secrets are machine-bound and cannot be transferred between machines.\n' + + " Each value will be encrypted with this machine's identity.\n", ); let secrets: SecretsManager | null = null; @@ -594,10 +616,7 @@ async function importBackup(args: string[]): Promise { secrets = await SecretsManager.create(); } catch (err) { p.log.error(`Failed to initialize secrets engine: ${String(err)}`); - p.log.info( - 'You can manually re-enter secrets later via ' + - theme.cmd('tinyclaw setup') + '.' - ); + p.log.info(`You can manually re-enter secrets later via ${theme.cmd('tinyclaw setup')}.`); } if (secrets) { @@ -629,7 +648,7 @@ async function importBackup(args: string[]): Promise { break; } - if (value && value.trim()) { + if (value?.trim()) { await secrets.store(key, value.trim()); stored++; } else { @@ -647,15 +666,18 @@ async function importBackup(args: string[]): Promise { if (skipped > 0) { p.log.info( theme.dim(' Skipped secrets can be added later via ') + - theme.cmd('tinyclaw setup') + - theme.dim('.') + theme.cmd('tinyclaw setup') + + theme.dim('.'), ); } } } p.outro( - theme.success('Import complete!') + ' Run ' + theme.cmd('tinyclaw start') + ' to boot your Tiny Claw.' + theme.success('Import complete!') + + ' Run ' + + theme.cmd('tinyclaw start') + + ' to boot your Tiny Claw.', ); } @@ -665,21 +687,27 @@ async function importBackup(args: string[]): Promise { function printHelp(): void { console.log(); - console.log(' ' + theme.label('Usage')); - console.log(` ${theme.cmd('tinyclaw backup export')} ${theme.dim('[path]')} Export a .tinyclaw backup archive`); - console.log(` ${theme.cmd('tinyclaw backup import')} ${theme.dim('')} Import a .tinyclaw backup archive`); + console.log(` ${theme.label('Usage')}`); + console.log( + ` ${theme.cmd('tinyclaw backup export')} ${theme.dim('[path]')} Export a .tinyclaw backup archive`, + ); + console.log( + ` ${theme.cmd('tinyclaw backup import')} ${theme.dim('')} Import a .tinyclaw backup archive`, + ); console.log(); - console.log(' ' + theme.label('Export')); + console.log(` ${theme.label('Export')}`); console.log(` Bundles all portable data (heartware, config, memory, learning)`); console.log(` into a single .tinyclaw file. Secrets are ${theme.warn('NOT')} included — only`); console.log(` their key names are saved so you know what to re-enter on import.`); - console.log(` Saved to ~/.tinyclaw/backups/ by default. Use ${theme.dim('.')} for current directory.`); + console.log( + ` Saved to ~/.tinyclaw/backups/ by default. Use ${theme.dim('.')} for current directory.`, + ); console.log(); - console.log(' ' + theme.label('Import')); + console.log(` ${theme.label('Import')}`); console.log(` Extracts the archive into ~/.tinyclaw/ and prompts you to`); console.log(` re-enter any secret values (API keys, tokens, etc).`); console.log(); - console.log(' ' + theme.label('Examples')); + console.log(` ${theme.label('Examples')}`); console.log(` ${theme.dim('$')} tinyclaw backup export`); console.log(` ${theme.dim('$')} tinyclaw backup export .`); console.log(` ${theme.dim('$')} tinyclaw backup import 2026-02-17T18-15-30.tinyclaw`); diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index 1f834c7..93d22a5 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -23,10 +23,10 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { ConfigManager } from '@tinyclaw/config'; import { - DEFAULT_MODEL, - DEFAULT_BASE_URL, - BUILTIN_MODELS, BUILTIN_MODEL_TAGS, + BUILTIN_MODELS, + DEFAULT_BASE_URL, + DEFAULT_MODEL, } from '@tinyclaw/core'; import { theme } from '../ui/theme.js'; @@ -40,15 +40,27 @@ type LogLevel = (typeof LOG_LEVELS)[number]; function printUsage(): void { console.log(); - console.log(' ' + theme.label('Usage')); - console.log(` ${theme.cmd('tinyclaw config model')} Show current model configuration`); - console.log(` ${theme.cmd('tinyclaw config model list')} List available built-in models`); - console.log(` ${theme.cmd('tinyclaw config model builtin')} Switch the built-in model`); - console.log(` ${theme.cmd('tinyclaw config model primary')} Show current primary provider`); - console.log(` ${theme.cmd('tinyclaw config model primary clear')} Remove primary provider override`); + console.log(` ${theme.label('Usage')}`); + console.log( + ` ${theme.cmd('tinyclaw config model')} Show current model configuration`, + ); + console.log( + ` ${theme.cmd('tinyclaw config model list')} List available built-in models`, + ); + console.log( + ` ${theme.cmd('tinyclaw config model builtin')} Switch the built-in model`, + ); + console.log( + ` ${theme.cmd('tinyclaw config model primary')} Show current primary provider`, + ); + console.log( + ` ${theme.cmd('tinyclaw config model primary clear')} Remove primary provider override`, + ); console.log(); console.log(` ${theme.cmd('tinyclaw config logging')} Show current log level`); - console.log(` ${theme.cmd('tinyclaw config logging')} Set log level (debug|info|warn|error|silent)`); + console.log( + ` ${theme.cmd('tinyclaw config logging')} Set log level (debug|info|warn|error|silent)`, + ); console.log(); } @@ -61,24 +73,29 @@ function printUsage(): void { */ async function showModelConfig(configManager: ConfigManager): Promise { const builtinModel = configManager.get('providers.starterBrain.model') ?? DEFAULT_MODEL; - const builtinBaseUrl = configManager.get('providers.starterBrain.baseUrl') ?? DEFAULT_BASE_URL; + const builtinBaseUrl = + configManager.get('providers.starterBrain.baseUrl') ?? DEFAULT_BASE_URL; const primaryModel = configManager.get('providers.primary.model'); const primaryBaseUrl = configManager.get('providers.primary.baseUrl'); console.log(); - console.log(' ' + theme.label('Model Configuration')); + console.log(` ${theme.label('Model Configuration')}`); console.log(); // Built-in section - console.log(` ${theme.label('Built-in')} ${theme.dim('(Ollama Cloud - always available as fallback)')}`); + console.log( + ` ${theme.label('Built-in')} ${theme.dim('(Ollama Cloud - always available as fallback)')}`, + ); console.log(` Model : ${theme.brand(builtinModel)}`); console.log(` Base URL : ${theme.dim(builtinBaseUrl)}`); console.log(); // Primary section if (primaryModel) { - console.log(` ${theme.label('Primary')} ${theme.dim('(overrides built-in as default provider)')}`); + console.log( + ` ${theme.label('Primary')} ${theme.dim('(overrides built-in as default provider)')}`, + ); console.log(` Model : ${theme.brand(primaryModel)}`); if (primaryBaseUrl) { console.log(` Base URL : ${theme.dim(primaryBaseUrl)}`); @@ -91,7 +108,9 @@ async function showModelConfig(configManager: ConfigManager): Promise { console.log(` No primary provider set. Built-in is used as the default.`); console.log(); console.log(` ${theme.dim('To add a primary provider, install a provider plugin and')}`); - console.log(` ${theme.dim('ask Tiny Claw to set it as primary. You can also tell Tiny Claw:')}`); + console.log( + ` ${theme.dim('ask Tiny Claw to set it as primary. You can also tell Tiny Claw:')}`, + ); console.log(` ${theme.dim('"list my providers" or "set OpenAI as my primary provider"')}`); } @@ -105,7 +124,7 @@ async function listModels(configManager: ConfigManager): Promise { const currentModel = configManager.get('providers.starterBrain.model') ?? DEFAULT_MODEL; console.log(); - console.log(' ' + theme.label('Available Built-in Models')); + console.log(` ${theme.label('Available Built-in Models')}`); console.log(); for (const model of BUILTIN_MODELS) { @@ -191,7 +210,7 @@ async function handlePrimary(configManager: ConfigManager, action?: string): Pro console.log(); if (primaryModel) { - console.log(' ' + theme.label('Primary Provider')); + console.log(` ${theme.label('Primary Provider')}`); console.log(); console.log(` Model : ${theme.brand(primaryModel)}`); if (primaryBaseUrl) { @@ -201,7 +220,9 @@ async function handlePrimary(configManager: ConfigManager, action?: string): Pro console.log(` API Key : ${theme.dim(`stored as "${primaryApiKeyRef}"`)}`); } console.log(); - console.log(` ${theme.dim('Clear with:')} ${theme.cmd('tinyclaw config model primary clear')}`); + console.log( + ` ${theme.dim('Clear with:')} ${theme.cmd('tinyclaw config model primary clear')}`, + ); } else { console.log(` ${theme.label('Primary Provider')} ${theme.dim('(not configured)')}`); console.log(); @@ -226,7 +247,7 @@ async function showLogLevel(configManager: ConfigManager): Promise { const current = configManager.get('logging.level') ?? 'info'; console.log(); - console.log(' ' + theme.label('Log Level')); + console.log(` ${theme.label('Log Level')}`); console.log(); for (const level of LOG_LEVELS) { @@ -238,7 +259,9 @@ async function showLogLevel(configManager: ConfigManager): Promise { console.log(); console.log(` ${theme.dim('Change with:')} ${theme.cmd('tinyclaw config logging ')}`); - console.log(` ${theme.dim('Override per session with:')} ${theme.cmd('tinyclaw start --verbose')}`); + console.log( + ` ${theme.dim('Override per session with:')} ${theme.cmd('tinyclaw start --verbose')}`, + ); console.log(); } diff --git a/src/cli/src/commands/purge.ts b/src/cli/src/commands/purge.ts index a2961e3..d05356c 100644 --- a/src/cli/src/commands/purge.ts +++ b/src/cli/src/commands/purge.ts @@ -14,14 +14,14 @@ * Uses @clack/prompts for interactive confirmation. */ -import { join } from 'path'; -import { homedir, platform } from 'os'; -import { rm, access, readFile } from 'fs/promises'; +import { access, readFile, rm } from 'node:fs/promises'; +import { homedir, platform } from 'node:os'; +import { join } from 'node:path'; import * as p from '@clack/prompts'; +import { generateSoul, parseSeed } from '@tinyclaw/heartware'; +import { setLogMode } from '@tinyclaw/logger'; import { showBanner } from '../ui/banner.js'; import { theme } from '../ui/theme.js'; -import { setLogMode } from '@tinyclaw/logger'; -import { parseSeed, generateSoul } from '@tinyclaw/heartware'; // --------------------------------------------------------------------------- // Path resolution @@ -103,10 +103,8 @@ export async function purgeCommand(args: string[] = []): Promise { if (!dataExists && (!flags.force || !secretsExist)) { p.intro(theme.brand('Purge')); - p.log.info('Nothing to purge — Tiny Claw hasn\'t been set up yet.'); - p.outro( - 'Run ' + theme.cmd('tinyclaw setup') + ' to get started.' - ); + p.log.info("Nothing to purge — Tiny Claw hasn't been set up yet."); + p.outro(`Run ${theme.cmd('tinyclaw setup')} to get started.`); return; } @@ -118,7 +116,9 @@ export async function purgeCommand(args: string[] = []): Promise { if (dataExists) { targets.push(` ${theme.label('Data directory')} ${theme.dim(dataDir)}`); - targets.push(` • data/ ${theme.dim('(config.db, agent.db, security.db + WAL/SHM files)')}`); + targets.push( + ` • data/ ${theme.dim('(config.db, agent.db, security.db + WAL/SHM files)')}`, + ); targets.push(` • learning/ ${theme.dim('(learned patterns)')}`); targets.push(` • heartware/ ${theme.dim('(identity files + backups)')}`); targets.push(` • audit/ ${theme.dim('(audit logs)')}`); @@ -135,12 +135,13 @@ export async function purgeCommand(args: string[] = []): Promise { } } else { targets.push(''); - targets.push(` ${theme.dim('Secrets store')} ${theme.dim('preserved (use --force to include)')}`); + targets.push( + ` ${theme.dim('Secrets store')} ${theme.dim('preserved (use --force to include)')}`, + ); } p.log.warn( - theme.error('This will permanently delete the following data:\n\n') + - targets.join('\n') + theme.error('This will permanently delete the following data:\n\n') + targets.join('\n'), ); // --- Type-to-confirm ------------------------------------------------ @@ -194,7 +195,9 @@ export async function purgeCommand(args: string[] = []): Promise { await rm(dataDir, rmOptions); // Verify deletion actually succeeded (locked files can cause silent partial removal) if (await dirExists(dataDir)) { - errors.push('Data directory: some files could not be removed (they may be locked by a running process)'); + errors.push( + 'Data directory: some files could not be removed (they may be locked by a running process)', + ); } else { deleted.push('Data directory'); } @@ -208,7 +211,9 @@ export async function purgeCommand(args: string[] = []): Promise { try { await rm(secretsDir, rmOptions); if (await dirExists(secretsDir)) { - errors.push('Secrets store: some files could not be removed (they may be locked by a running process)'); + errors.push( + 'Secrets store: some files could not be removed (they may be locked by a running process)', + ); } else { deleted.push('Secrets store'); } @@ -241,9 +246,7 @@ export async function purgeCommand(args: string[] = []): Promise { if (flags.force && deleted.includes('Secrets store')) { summary.push(''); - summary.push( - theme.warn(' ⚠ Secrets were deleted — you\'ll need a new API key during setup.') - ); + summary.push(theme.warn(" ⚠ Secrets were deleted — you'll need a new API key during setup.")); } p.log.info(summary.join('\n')); @@ -257,7 +260,5 @@ export async function purgeCommand(args: string[] = []): Promise { return; } - p.outro( - theme.success('Done!') + ' Run ' + theme.cmd('tinyclaw setup') + ' to reconfigure.' - ); + p.outro(`${theme.success('Done!')} Run ${theme.cmd('tinyclaw setup')} to reconfigure.`); } diff --git a/src/cli/src/commands/seed.ts b/src/cli/src/commands/seed.ts index 41df7bb..38abe61 100644 --- a/src/cli/src/commands/seed.ts +++ b/src/cli/src/commands/seed.ts @@ -8,10 +8,10 @@ * tinyclaw seed Show key soul traits */ -import { join } from 'path'; -import { readFile } from 'fs/promises'; -import { existsSync } from 'fs'; -import { homedir } from 'os'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; import { generateSoul, parseSeed } from '@tinyclaw/heartware'; import { theme } from '../ui/theme.js'; @@ -46,16 +46,32 @@ export async function seedCommand(): Promise { console.log(` Catchphrase: "${t.character.catchphrase}"`); console.log(); console.log(` ${theme.label('Personality (Big Five)')}`); - console.log(` Openness : ${bar(t.personality.openness)} ${(t.personality.openness * 100).toFixed(0)}%`); - console.log(` Conscientiousness : ${bar(t.personality.conscientiousness)} ${(t.personality.conscientiousness * 100).toFixed(0)}%`); - console.log(` Extraversion : ${bar(t.personality.extraversion)} ${(t.personality.extraversion * 100).toFixed(0)}%`); - console.log(` Agreeableness : ${bar(t.personality.agreeableness)} ${(t.personality.agreeableness * 100).toFixed(0)}%`); - console.log(` Emot. Sensitivity : ${bar(t.personality.emotionalSensitivity)} ${(t.personality.emotionalSensitivity * 100).toFixed(0)}%`); + console.log( + ` Openness : ${bar(t.personality.openness)} ${(t.personality.openness * 100).toFixed(0)}%`, + ); + console.log( + ` Conscientiousness : ${bar(t.personality.conscientiousness)} ${(t.personality.conscientiousness * 100).toFixed(0)}%`, + ); + console.log( + ` Extraversion : ${bar(t.personality.extraversion)} ${(t.personality.extraversion * 100).toFixed(0)}%`, + ); + console.log( + ` Agreeableness : ${bar(t.personality.agreeableness)} ${(t.personality.agreeableness * 100).toFixed(0)}%`, + ); + console.log( + ` Emot. Sensitivity : ${bar(t.personality.emotionalSensitivity)} ${(t.personality.emotionalSensitivity * 100).toFixed(0)}%`, + ); console.log(); console.log(` ${theme.label('Communication')}`); - console.log(` Verbosity : ${bar(t.communication.verbosity)} ${(t.communication.verbosity * 100).toFixed(0)}%`); - console.log(` Formality : ${bar(t.communication.formality)} ${(t.communication.formality * 100).toFixed(0)}%`); - console.log(` Emoji : ${bar(t.communication.emojiFrequency)} ${(t.communication.emojiFrequency * 100).toFixed(0)}%`); + console.log( + ` Verbosity : ${bar(t.communication.verbosity)} ${(t.communication.verbosity * 100).toFixed(0)}%`, + ); + console.log( + ` Formality : ${bar(t.communication.formality)} ${(t.communication.formality * 100).toFixed(0)}%`, + ); + console.log( + ` Emoji : ${bar(t.communication.emojiFrequency)} ${(t.communication.emojiFrequency * 100).toFixed(0)}%`, + ); console.log(` Humor : ${t.humor}`); console.log(); console.log(` ${theme.label('Favorites')}`); @@ -68,8 +84,12 @@ export async function seedCommand(): Promise { console.log(` ${theme.label('Values')}: ${t.values.join(', ')}`); console.log(` ${theme.label('Quirks')}: ${t.quirks.length} behavioral patterns`); console.log(); - console.log(theme.dim(' This soul is immutable — the same seed always produces the same personality.')); - console.log(theme.dim(' Share your seed with others so they can create a companion just like yours!')); + console.log( + theme.dim(' This soul is immutable — the same seed always produces the same personality.'), + ); + console.log( + theme.dim(' Share your seed with others so they can create a companion just like yours!'), + ); console.log(); } catch (err) { console.log(); diff --git a/src/cli/src/commands/setup-web.ts b/src/cli/src/commands/setup-web.ts index 943b2f4..701f1f5 100644 --- a/src/cli/src/commands/setup-web.ts +++ b/src/cli/src/commands/setup-web.ts @@ -6,16 +6,16 @@ * starts automatically via the supervisor restart mechanism. */ -import { join, resolve } from 'path'; -import { existsSync, statSync, readdirSync } from 'fs'; -import { homedir } from 'os'; +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { ConfigManager } from '@tinyclaw/config'; import { logger, setLogMode } from '@tinyclaw/logger'; import { SecretsManager } from '@tinyclaw/secrets'; -import { ConfigManager } from '@tinyclaw/config'; -import { createWebUI } from '@tinyclaw/web'; import type { StreamCallback } from '@tinyclaw/types'; -import { theme } from '../ui/theme.js'; +import { createWebUI } from '@tinyclaw/web'; import { RESTART_EXIT_CODE } from '../supervisor.js'; +import { theme } from '../ui/theme.js'; /** * Run the web-based setup flow. @@ -36,9 +36,13 @@ export async function webSetupCommand(): Promise { // --- Initialize engines ----------------------------------------------- const secretsManager = await SecretsManager.create(); - logger.info('Secrets engine initialized', { - storagePath: secretsManager.storagePath, - }, { emoji: '✅' }); + logger.info( + 'Secrets engine initialized', + { + storagePath: secretsManager.storagePath, + }, + { emoji: '✅' }, + ); const configManager = await ConfigManager.create(); logger.info('Config engine initialized', { configPath: configManager.path }, { emoji: '✅' }); @@ -46,11 +50,12 @@ export async function webSetupCommand(): Promise { // --- Launch setup-only web server ------------------------------------- const parsedPort = parseInt(process.env.PORT || '3000', 10); - const port = Number.isInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65535 - ? parsedPort - : 3000; + const port = + Number.isInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65535 ? parsedPort : 3000; if (process.env.PORT && port !== parsedPort) { - logger.warn(`Invalid PORT "${process.env.PORT}" — falling back to ${port}`, undefined, { emoji: '⚠️' }); + logger.warn(`Invalid PORT "${process.env.PORT}" — falling back to ${port}`, undefined, { + emoji: '⚠️', + }); } const setupOnlyMessage = 'Tiny Claw setup is not complete yet. Open /setup to finish onboarding, or run tinyclaw setup in the CLI.'; @@ -103,25 +108,29 @@ export async function webSetupCommand(): Promise { if (needsBuild) { if (!existsSync(webRoot)) { - logger.warn(`Web UI source not found at ${webRoot} — skipping build`, undefined, { emoji: '⚠️' }); - } else { - logger.info('Web UI build needed — building now...', undefined, { emoji: '🔨' }); - try { - const buildResult = Bun.spawnSync([process.execPath, 'run', 'build'], { - cwd: webRoot, - stdio: ['ignore', 'pipe', 'pipe'], + logger.warn(`Web UI source not found at ${webRoot} — skipping build`, undefined, { + emoji: '⚠️', }); - - if (buildResult.exitCode === 0) { - logger.info('Web UI built successfully', undefined, { emoji: '✅' }); - } else { - const stderr = buildResult.stderr?.toString().trim(); - logger.warn('Web UI build failed — setup page may not display correctly', undefined, { emoji: '⚠️' }); - if (stderr) logger.warn(stderr); + } else { + logger.info('Web UI build needed — building now...', undefined, { emoji: '🔨' }); + try { + const buildResult = Bun.spawnSync([process.execPath, 'run', 'build'], { + cwd: webRoot, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + if (buildResult.exitCode === 0) { + logger.info('Web UI built successfully', undefined, { emoji: '✅' }); + } else { + const stderr = buildResult.stderr?.toString().trim(); + logger.warn('Web UI build failed — setup page may not display correctly', undefined, { + emoji: '⚠️', + }); + if (stderr) logger.warn(stderr); + } + } catch (err) { + logger.warn('Could not build Web UI:', err, { emoji: '⚠️' }); } - } catch (err) { - logger.warn('Could not build Web UI:', err, { emoji: '⚠️' }); - } } } @@ -139,9 +148,21 @@ export async function webSetupCommand(): Promise { logger.info('Setup complete — starting Tiny Claw agent...', undefined, { emoji: '🚀' }); // Graceful shutdown: stop web server, then close managers before restart - try { await setupWebUI.stop(); } catch (err) { logger.warn('Error stopping web server during shutdown', { err }, { emoji: '⚠️' }); } - try { configManager.close(); } catch (err) { logger.warn('Error closing config manager during shutdown', { err }, { emoji: '⚠️' }); } // sync — ConfigManager.close() returns void - try { await secretsManager.close(); } catch (err) { logger.warn('Error closing secrets manager during shutdown', { err }, { emoji: '⚠️' }); } + try { + await setupWebUI.stop(); + } catch (err) { + logger.warn('Error stopping web server during shutdown', { err }, { emoji: '⚠️' }); + } + try { + configManager.close(); + } catch (err) { + logger.warn('Error closing config manager during shutdown', { err }, { emoji: '⚠️' }); + } // sync — ConfigManager.close() returns void + try { + await secretsManager.close(); + } catch (err) { + logger.warn('Error closing secrets manager during shutdown', { err }, { emoji: '⚠️' }); + } // Exit with restart code so the supervisor respawns as a normal start process.exit(RESTART_EXIT_CODE); diff --git a/src/cli/src/commands/setup.ts b/src/cli/src/commands/setup.ts index 3bada02..c554e56 100644 --- a/src/cli/src/commands/setup.ts +++ b/src/cli/src/commands/setup.ts @@ -14,43 +14,43 @@ * Uses @clack/prompts for a beautiful, lightweight terminal experience. */ -import { execSync } from 'child_process'; -import { join } from 'path'; -import { homedir } from 'os'; +import { execSync } from 'node:child_process'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; import * as p from '@clack/prompts'; -import QRCode from 'qrcode'; -import { createOllamaProvider } from '@tinyclaw/core'; -import { SecretsManager, buildProviderKeyName } from '@tinyclaw/secrets'; import { ConfigManager } from '@tinyclaw/config'; -import { parseSeed, generateRandomSeed, generateSoul } from '@tinyclaw/heartware'; import { - DEFAULT_PROVIDER, - DEFAULT_MODEL, - DEFAULT_BASE_URL, - SECURITY_WARNING_TITLE, - SECURITY_WARNING_BODY, - SECURITY_LICENSE, - SECURITY_WARRANTY, - SECURITY_SAFETY_TITLE, - SECURITY_SAFETY_PRACTICES, - SECURITY_CONFIRM, - defaultModelNote, - TOTP_SETUP_TITLE, - TOTP_SETUP_BODY, - BACKUP_CODES_INTRO, + BACKUP_CODES_COUNT, BACKUP_CODES_HINT, - RECOVERY_TOKEN_HINT, - generateTotpSecret, + BACKUP_CODES_INTRO, + createOllamaProvider, createTotpUri, - verifyTotpCode, + DEFAULT_BASE_URL, + DEFAULT_MODEL, + DEFAULT_PROVIDER, + defaultModelNote, generateBackupCodes, generateRecoveryToken, + generateTotpSecret, + RECOVERY_TOKEN_HINT, + SECURITY_CONFIRM, + SECURITY_LICENSE, + SECURITY_SAFETY_PRACTICES, + SECURITY_SAFETY_TITLE, + SECURITY_WARNING_BODY, + SECURITY_WARNING_TITLE, + SECURITY_WARRANTY, sha256, - BACKUP_CODES_COUNT, + TOTP_SETUP_BODY, + TOTP_SETUP_TITLE, + verifyTotpCode, } from '@tinyclaw/core'; +import { generateRandomSeed, generateSoul, parseSeed } from '@tinyclaw/heartware'; import { logger, setLogMode } from '@tinyclaw/logger'; -import { createWebUI } from '@tinyclaw/web'; +import { buildProviderKeyName, SecretsManager } from '@tinyclaw/secrets'; import type { StreamCallback } from '@tinyclaw/types'; +import { createWebUI } from '@tinyclaw/web'; +import QRCode from 'qrcode'; import { showBanner } from '../ui/banner.js'; import { theme } from '../ui/theme.js'; @@ -68,7 +68,10 @@ function copyToClipboard(text: string): boolean { } else { // Linux — try xclip first, fall back to xsel try { - execSync('xclip -selection clipboard', { input: text, stdio: ['pipe', 'ignore', 'ignore'] }); + execSync('xclip -selection clipboard', { + input: text, + stdio: ['pipe', 'ignore', 'ignore'], + }); } catch { execSync('xsel --clipboard --input', { input: text, stdio: ['pipe', 'ignore', 'ignore'] }); } @@ -82,9 +85,7 @@ function copyToClipboard(text: string): boolean { /** * Check if the user has already completed setup */ -async function isAlreadyConfigured( - secrets: SecretsManager -): Promise { +async function isAlreadyConfigured(secrets: SecretsManager): Promise { return await secrets.check(buildProviderKeyName('ollama')); } @@ -103,19 +104,24 @@ export async function setupWebCommand(): Promise { logger.info('Data directory:', { dataDir }, { emoji: '\ud83d\udcc2' }); const secretsManager = await SecretsManager.create(); - logger.info('Secrets engine initialized', { - storagePath: secretsManager.storagePath, - }, { emoji: '\u2705' }); + logger.info( + 'Secrets engine initialized', + { + storagePath: secretsManager.storagePath, + }, + { emoji: '\u2705' }, + ); const configManager = await ConfigManager.create(); logger.info('Config engine initialized', { configPath: configManager.path }, { emoji: '\u2705' }); const parsedPort = parseInt(process.env.PORT || '3000', 10); - const port = Number.isInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65535 - ? parsedPort - : 3000; + const port = + Number.isInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65535 ? parsedPort : 3000; if (process.env.PORT && port !== parsedPort) { - logger.warn(`Invalid PORT "${process.env.PORT}" — falling back to ${port}`, undefined, { emoji: '\u26a0\ufe0f' }); + logger.warn(`Invalid PORT "${process.env.PORT}" — falling back to ${port}`, undefined, { + emoji: '\u26a0\ufe0f', + }); } const setupOnlyMessage = 'Tiny Claw setup is not complete yet. Open /setup to finish onboarding, or run tinyclaw setup in the CLI.'; @@ -123,8 +129,12 @@ export async function setupWebCommand(): Promise { logger.info('\u2500'.repeat(52), undefined, { emoji: '' }); logger.warn('Web setup mode enabled (--web).', undefined, { emoji: '\u26a0\ufe0f' }); logger.info('Choose your onboarding path:', undefined, { emoji: '\ud83d\udccb' }); - logger.info(`1. ${theme.cmd('tinyclaw setup')} ${theme.dim('(CLI wizard)')}`, undefined, { emoji: '\ud83d\udccb' }); - logger.info(`2. ${theme.cmd('tinyclaw setup --web')} ${theme.dim('(Web setup)')}`, undefined, { emoji: '\ud83d\udccb' }); + logger.info(`1. ${theme.cmd('tinyclaw setup')} ${theme.dim('(CLI wizard)')}`, undefined, { + emoji: '\ud83d\udccb', + }); + logger.info(`2. ${theme.cmd('tinyclaw setup --web')} ${theme.dim('(Web setup)')}`, undefined, { + emoji: '\ud83d\udccb', + }); logger.info('\u2500'.repeat(52), undefined, { emoji: '' }); let setupWebUI: ReturnType | null = null; @@ -151,12 +161,18 @@ export async function setupWebCommand(): Promise { await setupWebUI.start(); logger.info('Setup-only web server is running', 'web', { emoji: '\ud83d\udee0\ufe0f' }); - logger.info(`Open: ${theme.brand(`http://localhost:${port}/setup`)}`, 'web', { emoji: '\ud83d\udd17' }); + logger.info(`Open: ${theme.brand(`http://localhost:${port}/setup`)}`, 'web', { + emoji: '\ud83d\udd17', + }); } catch (err) { logger.error('Failed to start web setup server', { err }, { emoji: '\u274c' }); // Graceful cleanup on error if (setupWebUI) { - try { await setupWebUI.stop(); } catch { /* ignore */ } + try { + await setupWebUI.stop(); + } catch { + /* ignore */ + } } await cleanup(secretsManager, configManager); throw err; @@ -166,7 +182,11 @@ export async function setupWebCommand(): Promise { const gracefulShutdown = async () => { logger.info('Shutting down web setup server...', undefined, { emoji: '\ud83d\udeab' }); if (setupWebUI) { - try { await setupWebUI.stop(); } catch { /* ignore */ } + try { + await setupWebUI.stop(); + } catch { + /* ignore */ + } } await cleanup(secretsManager, configManager); process.exit(0); @@ -188,17 +208,22 @@ export async function setupCommand(): Promise { const secretsManager = await SecretsManager.create(); const configManager = await ConfigManager.create(); - p.intro(theme.brand('Let\'s set up Tiny Claw')); + p.intro(theme.brand("Let's set up Tiny Claw")); // --- Security warning ----------------------------------------------- p.note( - theme.warn(SECURITY_WARNING_TITLE) + '\n\n' + - SECURITY_WARNING_BODY + '\n\n' + - theme.label(SECURITY_LICENSE) + '\n\n' + - theme.label(SECURITY_WARRANTY) + '\n\n' + - theme.label(SECURITY_SAFETY_TITLE) + '\n' + - SECURITY_SAFETY_PRACTICES.map(item => ` • ${item}`).join('\n'), + theme.warn(SECURITY_WARNING_TITLE) + + '\n\n' + + SECURITY_WARNING_BODY + + '\n\n' + + theme.label(SECURITY_LICENSE) + + '\n\n' + + theme.label(SECURITY_WARRANTY) + + '\n\n' + + theme.label(SECURITY_SAFETY_TITLE) + + '\n' + + SECURITY_SAFETY_PRACTICES.map((item) => ` • ${item}`).join('\n'), 'Security', ); @@ -228,14 +253,14 @@ export async function setupCommand(): Promise { p.log.info( `Existing configuration found:\n` + - ` Provider : ${theme.label('Ollama Cloud')}\n` + - ` Model : ${theme.label(DEFAULT_MODEL)}\n` + - ` Base URL : ${theme.label(DEFAULT_BASE_URL)}\n` + - ` API Key : ${theme.dim('•••••••• (stored in secrets-engine)')}\n\n` + - ` Soul seed:\n` + - ` Seed : ${theme.label(String(savedSeed))}\n` + - ` Name : ${theme.label(st.character.suggestedName)}\n` + - ` Values : ${theme.label(st.values.join(', '))}` + ` Provider : ${theme.label('Ollama Cloud')}\n` + + ` Model : ${theme.label(DEFAULT_MODEL)}\n` + + ` Base URL : ${theme.label(DEFAULT_BASE_URL)}\n` + + ` API Key : ${theme.dim('•••••••• (stored in secrets-engine)')}\n\n` + + ` Soul seed:\n` + + ` Seed : ${theme.label(String(savedSeed))}\n` + + ` Name : ${theme.label(st.character.suggestedName)}\n` + + ` Values : ${theme.label(st.values.join(', '))}`, ); const reconfigure = await p.confirm({ @@ -253,111 +278,113 @@ export async function setupCommand(): Promise { if (partiallyConfigured) { p.log.warn( `Incomplete setup detected — API key is stored but no soul seed was set.\n` + - `Resuming setup from the soul seed step.` + `Resuming setup from the soul seed step.`, ); } // --- Steps 1-3: API key, validation, model (skip if partially configured) --- if (!partiallyConfigured) { + // --- Step 1: API key ------------------------------------------------ + + p.note( + `${theme.label('Ollama Cloud')} is the default provider that powers Tiny Claw.\n` + + "It's free to sign up and comes with a generous free tier,\n" + + 'so you can take your time exploring what Tiny Claw can do.\n\n' + + theme.label('How to get your API key:') + + '\n' + + ` 1. Go to ${theme.label('https://ollama.com')} and create a free account.\n` + + ' 2. Navigate to your account settings \u2192 API keys.\n' + + ' 3. Generate a new key and paste it below.\n\n' + + theme.dim( + 'Shout-out to the Ollama team for their generosity, making it\n' + + 'possible for anyone to try Tiny Claw at zero cost. Thank you! \ud83d\ude4f', + ), + 'Default Provider', + ); - // --- Step 1: API key ------------------------------------------------ - - p.note( - `${theme.label('Ollama Cloud')} is the default provider that powers Tiny Claw.\n` + - 'It\'s free to sign up and comes with a generous free tier,\n' + - 'so you can take your time exploring what Tiny Claw can do.\n\n' + - theme.label('How to get your API key:') + '\n' + - ` 1. Go to ${theme.label('https://ollama.com')} and create a free account.\n` + - ' 2. Navigate to your account settings \u2192 API keys.\n' + - ' 3. Generate a new key and paste it below.\n\n' + - theme.dim('Shout-out to the Ollama team for their generosity, making it\n' + - 'possible for anyone to try Tiny Claw at zero cost. Thank you! \ud83d\ude4f'), - 'Default Provider', - ); - - const apiKey = await p.password({ - message: 'Enter your Ollama Cloud API key', - validate: (value) => { - if (!value || value.trim().length === 0) { - return 'API key is required'; - } - }, - }); + const apiKey = await p.password({ + message: 'Enter your Ollama Cloud API key', + validate: (value) => { + if (!value || value.trim().length === 0) { + return 'API key is required'; + } + }, + }); - if (p.isCancel(apiKey)) { - p.outro(theme.dim('Setup cancelled.')); - await cleanup(secretsManager, configManager); - return; - } + if (p.isCancel(apiKey)) { + p.outro(theme.dim('Setup cancelled.')); + await cleanup(secretsManager, configManager); + return; + } - // --- Step 2: Validate API key --------------------------------------- + // --- Step 2: Validate API key --------------------------------------- - const verifySpinner = p.spinner(); - verifySpinner.start('Validating your API key'); + const verifySpinner = p.spinner(); + verifySpinner.start('Validating your API key'); - let keyValid = false; + let keyValid = false; - try { - // Temporarily store the key so the provider can resolve it - await secretsManager.store(buildProviderKeyName(DEFAULT_PROVIDER), apiKey.trim()); + try { + // Temporarily store the key so the provider can resolve it + await secretsManager.store(buildProviderKeyName(DEFAULT_PROVIDER), apiKey.trim()); - const ollamaProvider = createOllamaProvider({ - secrets: secretsManager, - model: DEFAULT_MODEL, - baseUrl: DEFAULT_BASE_URL, - }); + const ollamaProvider = createOllamaProvider({ + secrets: secretsManager, + model: DEFAULT_MODEL, + baseUrl: DEFAULT_BASE_URL, + }); - keyValid = await ollamaProvider.isAvailable(); + keyValid = await ollamaProvider.isAvailable(); - if (keyValid) { - verifySpinner.stop(theme.success('API key is valid')); - } else { - verifySpinner.stop(theme.warn('Could not verify API key')); - p.log.warn( - 'The provider is not reachable. Your key has been saved anyway.\n' + - 'You can re-run ' + theme.cmd('tinyclaw setup') + ' to reconfigure.' - ); + if (keyValid) { + verifySpinner.stop(theme.success('API key is valid')); + } else { + verifySpinner.stop(theme.warn('Could not verify API key')); + p.log.warn( + 'The provider is not reachable. Your key has been saved anyway.\n' + + 'You can re-run ' + + theme.cmd('tinyclaw setup') + + ' to reconfigure.', + ); + } + } catch (err) { + verifySpinner.stop(theme.warn('Verification failed')); + p.log.warn(`Could not validate the key, but it has been saved.\nError: ${String(err)}`); } - } catch (err) { - verifySpinner.stop(theme.warn('Verification failed')); - p.log.warn( - 'Could not validate the key, but it has been saved.\n' + - 'Error: ' + String(err) - ); - } - // --- Step 3: Default model confirmation ----------------------------- + // --- Step 3: Default model confirmation ----------------------------- - p.note( - defaultModelNote(theme.label(DEFAULT_MODEL)), - 'Default Model', - ); - - const understood = await p.confirm({ - message: 'Got it, let\'s continue', - initialValue: true, - }); + p.note(defaultModelNote(theme.label(DEFAULT_MODEL)), 'Default Model'); - if (p.isCancel(understood) || !understood) { - p.outro(theme.dim('Setup cancelled.')); - await cleanup(secretsManager, configManager); - return; - } + const understood = await p.confirm({ + message: "Got it, let's continue", + initialValue: true, + }); + if (p.isCancel(understood) || !understood) { + p.outro(theme.dim('Setup cancelled.')); + await cleanup(secretsManager, configManager); + return; + } } // end skip for partial setup // --- Step 4: Soul seed ------------------------------------------------ p.note( - 'Your Tiny Claw\'s personality is generated from a ' + theme.label('soul seed') + ',\n' + - 'just like Minecraft\'s world generation. The same seed always\n' + - 'produces the same personality \u2014 unique traits, quirks, and values.\n\n' + - theme.label('Options:') + '\n' + - ' \u2022 Enter a specific number to get a personality you can reproduce.\n' + - ' \u2022 Leave blank to let Tiny Claw pick a random seed.\n\n' + - theme.dim('Once set, the soul seed is permanent and cannot be changed.\n' + - 'Share your seed with others so they can create a companion just like yours!'), + "Your Tiny Claw's personality is generated from a " + + theme.label('soul seed') + + ',\n' + + "just like Minecraft's world generation. The same seed always\n" + + 'produces the same personality \u2014 unique traits, quirks, and values.\n\n' + + theme.label('Options:') + + '\n' + + ' \u2022 Enter a specific number to get a personality you can reproduce.\n' + + ' \u2022 Leave blank to let Tiny Claw pick a random seed.\n\n' + + theme.dim( + 'Once set, the soul seed is permanent and cannot be changed.\n' + + 'Share your seed with others so they can create a companion just like yours!', + ), 'Soul Seed', ); @@ -395,8 +422,8 @@ export async function setupCommand(): Promise { p.log.info( `${theme.label('Seed')} : ${soulSeed}\n` + - `${theme.label('Name')} : ${t.character.suggestedName}\n` + - `${theme.label('Values')} : ${t.values.join(', ')}` + `${theme.label('Name')} : ${t.character.suggestedName}\n` + + `${theme.label('Values')} : ${t.values.join(', ')}`, ); const soulAction = await p.select({ @@ -445,10 +472,12 @@ export async function setupCommand(): Promise { // --- Step 5: TOTP setup --------------------------------------------- p.note( - theme.label(TOTP_SETUP_TITLE) + '\n\n' + - TOTP_SETUP_BODY + '\n\n' + - 'Two-factor authentication protects your Tiny Claw instance.\n' + - 'You\'ll need this to log in via the web dashboard.', + theme.label(TOTP_SETUP_TITLE) + + '\n\n' + + TOTP_SETUP_BODY + + '\n\n' + + 'Two-factor authentication protects your Tiny Claw instance.\n' + + "You'll need this to log in via the web dashboard.", 'Two-Factor Authentication', ); @@ -459,10 +488,13 @@ export async function setupCommand(): Promise { const qrText = await QRCode.toString(totpUri, { type: 'terminal', small: true }); p.log.info( - theme.label('Scan this QR code with your authenticator app:') + '\n\n' + - qrText + '\n' + - theme.label('Or enter the secret manually:') + '\n' + - ` ${theme.cmd(totpSecret)}` + theme.label('Scan this QR code with your authenticator app:') + + '\n\n' + + qrText + + '\n' + + theme.label('Or enter the secret manually:') + + '\n' + + ` ${theme.cmd(totpSecret)}`, ); let totpVerified = false; @@ -514,29 +546,38 @@ export async function setupCommand(): Promise { // Break the long recovery token into readable chunks (40 chars per line) const tokenChunks = recoveryToken.match(/.{1,40}/g) || [recoveryToken]; - p.note( - theme.warn(BACKUP_CODES_INTRO), - 'Recovery Information', - ); + p.note(theme.warn(BACKUP_CODES_INTRO), 'Recovery Information'); p.log.info( - theme.label('Recovery Token:') + '\n' + - tokenChunks.map(chunk => ` ${theme.cmd(chunk)}`).join('\n') + '\n\n' + - theme.dim(RECOVERY_TOKEN_HINT) + theme.label('Recovery Token:') + + '\n' + + tokenChunks.map((chunk) => ` ${theme.cmd(chunk)}`).join('\n') + + '\n\n' + + theme.dim(RECOVERY_TOKEN_HINT), ); p.log.info( - theme.label('Backup Codes:') + '\n' + - backupCodes.map((code, i) => ` ${String(i + 1).padStart(2, ' ')}. ${code}`).join('\n') + '\n\n' + - theme.dim(BACKUP_CODES_HINT) + theme.label('Backup Codes:') + + '\n' + + backupCodes.map((code, i) => ` ${String(i + 1).padStart(2, ' ')}. ${code}`).join('\n') + + '\n\n' + + theme.dim(BACKUP_CODES_HINT), ); // Offer to copy recovery credentials to clipboard const copyMode = await p.select({ message: 'How would you like to save your credentials?', options: [ - { value: 'all', label: 'Copy all at once', hint: 'recovery token + backup codes → clipboard' }, - { value: 'step', label: 'Copy one at a time', hint: 'recovery token first, then backup codes' }, + { + value: 'all', + label: 'Copy all at once', + hint: 'recovery token + backup codes → clipboard', + }, + { + value: 'step', + label: 'Copy one at a time', + hint: 'recovery token first, then backup codes', + }, ], }); @@ -565,7 +606,7 @@ export async function setupCommand(): Promise { } const copyCodesConfirm = await p.confirm({ - message: 'I\'ve saved the recovery token — copy backup codes next', + message: "I've saved the recovery token — copy backup codes next", initialValue: true, }); @@ -646,20 +687,20 @@ export async function setupCommand(): Promise { p.log.success( `${theme.label('Provider')} : Ollama Cloud\n` + - `${theme.label('Model')} : ${DEFAULT_MODEL}\n` + - `${theme.label('Base URL')} : ${DEFAULT_BASE_URL}\n` + - `${theme.label('API Key')} : ${theme.dim('•••••••• (encrypted)')}\n` + - `${theme.label('Soul Seed')} : ${soulSeed}` + `${theme.label('Model')} : ${DEFAULT_MODEL}\n` + + `${theme.label('Base URL')} : ${DEFAULT_BASE_URL}\n` + + `${theme.label('API Key')} : ${theme.dim('•••••••• (encrypted)')}\n` + + `${theme.label('Soul Seed')} : ${soulSeed}`, ); p.log.info( - theme.dim('Your recovery token, backup codes, and TOTP secret have been\n' + - 'cleared from the terminal for security. Make sure you saved them!') + theme.dim( + 'Your recovery token, backup codes, and TOTP secret have been\n' + + 'cleared from the terminal for security. Make sure you saved them!', + ), ); - p.outro( - theme.success('You\'re all set!') + ' Run ' + theme.cmd('tinyclaw start') + ' to begin.' - ); + p.outro(`${theme.success("You're all set!")} Run ${theme.cmd('tinyclaw start')} to begin.`); await cleanup(secretsManager, configManager); } @@ -668,6 +709,14 @@ export async function setupCommand(): Promise { * Gracefully close manager connections */ async function cleanup(secrets: SecretsManager, config: ConfigManager): Promise { - try { config.close(); } catch { /* ignore */ } - try { await secrets.close(); } catch { /* ignore */ } + try { + config.close(); + } catch { + /* ignore */ + } + try { + await secrets.close(); + } catch { + /* ignore */ + } } diff --git a/src/cli/src/commands/start.ts b/src/cli/src/commands/start.ts index 72a3cc3..3ded4b4 100644 --- a/src/cli/src/commands/start.ts +++ b/src/cli/src/commands/start.ts @@ -9,42 +9,59 @@ * `tinyclaw setup`. */ -import { join, resolve } from 'path'; -import { existsSync, statSync, readdirSync } from 'fs'; -import { homedir } from 'os'; +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { createCompactor } from '@tinyclaw/compactor'; +import { ConfigManager, createConfigTools } from '@tinyclaw/config'; import { - createDatabase, agentLoop, - createOllamaProvider, - DEFAULT_MODEL, - DEFAULT_BASE_URL, BUILTIN_MODEL_TAGS, - checkForUpdate, buildUpdateContext, + checkForUpdate, + createDatabase, + createOllamaProvider, + DEFAULT_BASE_URL, + DEFAULT_MODEL, } from '@tinyclaw/core'; -import { loadPlugins } from '@tinyclaw/plugins'; -import { createPulseScheduler } from '@tinyclaw/pulse'; +import { + createBlackboard, + createDelegationTools, + createTimeoutEstimator, +} from '@tinyclaw/delegation'; +import { createGateway } from '@tinyclaw/gateway'; +import { + createHeartwareTools, + type HeartwareConfig, + HeartwareManager, + loadHeartwareContext, + loadShieldContent, + parseSeed, +} from '@tinyclaw/heartware'; import { createIntercom } from '@tinyclaw/intercom'; +import { createLearningEngine } from '@tinyclaw/learning'; +import { type LogModeName, logger, setLogMode } from '@tinyclaw/logger'; import { createHybridMatcher } from '@tinyclaw/matcher'; +import { createMemoryEngine } from '@tinyclaw/memory'; +import { + createCompanionJobs, + createNudgeEngine, + createNudgeTools, + getCompanionTouchActivity, + wireNudgeToIntercom, +} from '@tinyclaw/nudge'; +import { loadPlugins } from '@tinyclaw/plugins'; +import { createPulseScheduler } from '@tinyclaw/pulse'; import { createSessionQueue } from '@tinyclaw/queue'; -import { logger, setLogMode, type LogModeName } from '@tinyclaw/logger'; import { ProviderOrchestrator, type ProviderTierConfig } from '@tinyclaw/router'; -import { HeartwareManager, createHeartwareTools, loadHeartwareContext, loadShieldContent, parseSeed, type HeartwareConfig } from '@tinyclaw/heartware'; -import { createLearningEngine } from '@tinyclaw/learning'; -import { SecretsManager, createSecretsTools, buildProviderKeyName } from '@tinyclaw/secrets'; -import { ConfigManager, createConfigTools } from '@tinyclaw/config'; -import { createDelegationTools, createBlackboard, createTimeoutEstimator } from '@tinyclaw/delegation'; -import { createMemoryEngine } from '@tinyclaw/memory'; import { createSandbox } from '@tinyclaw/sandbox'; -import { createShieldEngine } from '@tinyclaw/shield'; -import { createCompactor } from '@tinyclaw/compactor'; +import { buildProviderKeyName, createSecretsTools, SecretsManager } from '@tinyclaw/secrets'; import { createShellEngine, createShellTools } from '@tinyclaw/shell'; -import type { ChannelPlugin, Provider, StreamCallback, Tool } from '@tinyclaw/types'; +import { createShieldEngine } from '@tinyclaw/shield'; +import type { Provider, StreamCallback, Tool } from '@tinyclaw/types'; import { createWebUI } from '@tinyclaw/web'; -import { createGateway } from '@tinyclaw/gateway'; -import { createNudgeEngine, wireNudgeToIntercom, createNudgeTools, createCompanionJobs, getCompanionTouchActivity } from '@tinyclaw/nudge'; -import { theme } from '../ui/theme.js'; import { RESTART_EXIT_CODE } from '../supervisor.js'; +import { theme } from '../ui/theme.js'; /** * Run the agent start flow @@ -115,9 +132,13 @@ export async function startCommand(): Promise { throw err; } - logger.info('Secrets engine initialized', { - storagePath: secretsManager.storagePath, - }, { emoji: '✅' }); + logger.info( + 'Secrets engine initialized', + { + storagePath: secretsManager.storagePath, + }, + { emoji: '✅' }, + ); // --- Initialize config engine ----------------------------------------- @@ -137,9 +158,7 @@ export async function startCommand(): Promise { // must be present for the agent to run. After a purge, the config DB // (soul seed + owner auth) is wiped even if secrets are preserved. - const hasOllamaKey = await secretsManager.check( - buildProviderKeyName('ollama') - ); + const hasOllamaKey = await secretsManager.check(buildProviderKeyName('ollama')); const hasSoulSeed = configManager.get('heartware.seed') !== undefined; const hasOwnerAuth = configManager.get('owner.ownerId') !== undefined; @@ -154,19 +173,26 @@ export async function startCommand(): Promise { logger.info('─'.repeat(52), undefined, { emoji: '' }); logger.warn(reason, undefined, { emoji: '⚠️' }); logger.info('Choose your onboarding path:', undefined, { emoji: '📋' }); - logger.info(`1. ${theme.cmd('tinyclaw setup')} ${theme.dim('(CLI wizard)')}`, undefined, { emoji: '📋' }); - logger.info(`2. ${theme.cmd('tinyclaw setup --web')} ${theme.dim('(Web setup)')}`, undefined, { emoji: '📋' }); + logger.info(`1. ${theme.cmd('tinyclaw setup')} ${theme.dim('(CLI wizard)')}`, undefined, { + emoji: '📋', + }); + logger.info(`2. ${theme.cmd('tinyclaw setup --web')} ${theme.dim('(Web setup)')}`, undefined, { + emoji: '📋', + }); logger.info('─'.repeat(52), undefined, { emoji: '' }); // Clean up managers before exiting configManager.close(); - try { await secretsManager.close(); } catch { /* ignore */ } + try { + await secretsManager.close(); + } catch { + /* ignore */ + } process.exit(1); } // Read provider settings from config (fallback to defaults) - const providerModel = - configManager.get('providers.starterBrain.model') ?? DEFAULT_MODEL; + const providerModel = configManager.get('providers.starterBrain.model') ?? DEFAULT_MODEL; const providerBaseUrl = configManager.get('providers.starterBrain.baseUrl') ?? DEFAULT_BASE_URL; @@ -203,14 +229,16 @@ export async function startCommand(): Promise { // --- Initialize SHIELD.md runtime enforcement ------------------------- const shieldContent = await loadShieldContent(heartwareManager); - const shield = shieldContent - ? createShieldEngine(shieldContent) - : undefined; + const shield = shieldContent ? createShieldEngine(shieldContent) : undefined; if (shield) { - logger.info('Shield engine active', { - threats: shield.getThreats().length, - }, { emoji: '🛡️' }); + logger.info( + 'Shield engine active', + { + threats: shield.getThreats().length, + }, + { emoji: '🛡️' }, + ); } else { logger.info('No SHIELD.md found — shield enforcement disabled', undefined, { emoji: '🛡️' }); } @@ -239,9 +267,7 @@ export async function startCommand(): Promise { console.log(' • The base URL may be incorrect'); console.log(' • A firewall or proxy may be blocking the connection'); console.log(); - console.log( - ` Run ${theme.cmd('tinyclaw setup')} to reconfigure your provider.`, - ); + console.log(` Run ${theme.cmd('tinyclaw setup')} to reconfigure your provider.`); console.log(); await secretsManager.close(); process.exit(1); @@ -258,9 +284,7 @@ export async function startCommand(): Promise { console.log(` Base URL : ${theme.dim(providerBaseUrl)}`); console.log(); console.log(' Your API key may be invalid, expired, or revoked.'); - console.log( - ` Run ${theme.cmd('tinyclaw setup')} to enter a new API key.`, - ); + console.log(` Run ${theme.cmd('tinyclaw setup')} to enter a new API key.`); } else { console.log(theme.error(' ✖ Provider connectivity check failed.')); console.log(); @@ -268,9 +292,7 @@ export async function startCommand(): Promise { console.log(` Base URL : ${theme.dim(providerBaseUrl)}`); console.log(` Error : ${theme.dim(msg)}`); console.log(); - console.log( - ` Run ${theme.cmd('tinyclaw setup')} to reconfigure your provider.`, - ); + console.log(` Run ${theme.cmd('tinyclaw setup')} to reconfigure your provider.`); } console.log(); await secretsManager.close(); @@ -282,11 +304,15 @@ export async function startCommand(): Promise { // --- Load plugins ------------------------------------------------------ const plugins = await loadPlugins(configManager); - logger.info('Plugins loaded', { - channels: plugins.channels.length, - providers: plugins.providers.length, - tools: plugins.tools.length, - }, { emoji: '✅' }); + logger.info( + 'Plugins loaded', + { + channels: plugins.channels.length, + providers: plugins.providers.length, + tools: plugins.tools.length, + }, + { emoji: '✅' }, + ); // --- Initialize plugin providers --------------------------------------- @@ -296,7 +322,9 @@ export async function startCommand(): Promise { try { const provider = await pp.createProvider(secretsManager); pluginProviders.push(provider); - logger.info(`Plugin provider initialized: ${pp.name} (${provider.id})`, undefined, { emoji: '✅' }); + logger.info(`Plugin provider initialized: ${pp.name} (${provider.id})`, undefined, { + emoji: '✅', + }); } catch (err) { logger.error(`Failed to initialize provider plugin "${pp.name}":`, err); } @@ -317,8 +345,8 @@ export async function startCommand(): Promise { if (primaryModel) { // Find a plugin provider whose id matches the primary config. // Convention: the provider ID from the plugin is used to look up matching. - const primaryBaseUrl = configManager.get('providers.primary.baseUrl'); - const primaryApiKeyRef = configManager.get('providers.primary.apiKeyRef'); + const _primaryBaseUrl = configManager.get('providers.primary.baseUrl'); + const _primaryApiKeyRef = configManager.get('providers.primary.apiKeyRef'); // Try to find a matching plugin provider by checking if any plugin // provider's id is referenced in the tier mapping or matches a known pattern. @@ -336,24 +364,26 @@ export async function startCommand(): Promise { routerDefaultProvider = matchingProvider; activeProviderName = matchingProvider.name; activeModelName = primaryModel; - logger.info('Primary provider active, overriding built-in as default', { - primary: matchingProvider.id, - model: primaryModel, - }, { emoji: '✅' }); + logger.info( + 'Primary provider active, overriding built-in as default', + { + primary: matchingProvider.id, + model: primaryModel, + }, + { emoji: '✅' }, + ); } else { logger.warn( `Primary provider "${matchingProvider.name}" unavailable, falling back to built-in`, ); } } catch { - logger.warn( - `Primary provider health check failed, falling back to built-in`, - ); + logger.warn(`Primary provider health check failed, falling back to built-in`); } } else { logger.warn( 'Primary provider configured but no matching plugin provider found. ' + - 'Ensure the provider plugin is installed and enabled.', + 'Ensure the provider plugin is installed and enabled.', ); } } @@ -372,11 +402,15 @@ export async function startCommand(): Promise { tierMapping: tierMapping ?? undefined, }); - logger.info('Smart routing initialized', { - default: routerDefaultProvider.id, - providers: orchestrator.getRegistry().ids(), - tierMapping: tierMapping ?? 'all-default', - }, { emoji: '✅' }); + logger.info( + 'Smart routing initialized', + { + default: routerDefaultProvider.id, + providers: orchestrator.getRegistry().ids(), + tierMapping: tierMapping ?? 'all-default', + }, + { emoji: '✅' }, + ); // --- Initialize tools ------------------------------------------------- @@ -388,12 +422,8 @@ export async function startCommand(): Promise { // Merge plugin pairing tools (channels + providers) const pairingTools = [ - ...plugins.channels.flatMap( - (ch) => ch.getPairingTools?.(secretsManager, configManager) ?? [], - ), - ...plugins.providers.flatMap( - (pp) => pp.getPairingTools?.(secretsManager, configManager) ?? [], - ), + ...plugins.channels.flatMap((ch) => ch.getPairingTools?.(secretsManager, configManager) ?? []), + ...plugins.providers.flatMap((pp) => pp.getPairingTools?.(secretsManager, configManager) ?? []), ]; // Create a temporary context for plugin tools that need AgentContext @@ -407,9 +437,7 @@ export async function startCommand(): Promise { configManager, }; - const pluginTools = plugins.tools.flatMap( - (tp) => tp.createTools(baseContext), - ); + const pluginTools = plugins.tools.flatMap((tp) => tp.createTools(baseContext)); const allTools = [...tools, ...pairingTools, ...pluginTools]; @@ -426,7 +454,9 @@ export async function startCommand(): Promise { // Memory engine (after db — uses episodic_memory + memory_fts tables) const memoryEngine = createMemoryEngine(db); - logger.info('Memory engine initialized (episodic + FTS5 + temporal decay)', undefined, { emoji: '✅' }); + logger.info('Memory engine initialized (episodic + FTS5 + temporal decay)', undefined, { + emoji: '✅', + }); // Compactor (after db — uses compactions table, configurable via config engine) const compactorConfig = { @@ -439,20 +469,24 @@ export async function startCommand(): Promise { }, dedup: { enabled: configManager.get('compaction.dedup.enabled') ?? undefined, - similarityThreshold: configManager.get('compaction.dedup.similarityThreshold') ?? undefined, + similarityThreshold: + configManager.get('compaction.dedup.similarityThreshold') ?? undefined, }, preCompression: { stripEmoji: configManager.get('compaction.preCompression.stripEmoji') ?? undefined, - removeDuplicateLines: configManager.get('compaction.preCompression.removeDuplicateLines') ?? undefined, + removeDuplicateLines: + configManager.get('compaction.preCompression.removeDuplicateLines') ?? undefined, }, }; // Remove undefined values so defaults apply const cleanConfig = JSON.parse(JSON.stringify(compactorConfig)); const compactor = createCompactor(db, cleanConfig); - logger.info('Compactor initialized (tiered compression + dedup + pre-compression)', undefined, { emoji: '✅' }); + logger.info('Compactor initialized (tiered compression + dedup + pre-compression)', undefined, { + emoji: '✅', + }); // Hybrid semantic matcher (standalone, no deps) - const matcher = createHybridMatcher(); + const _matcher = createHybridMatcher(); logger.info('Hybrid matcher initialized', undefined, { emoji: '✅' }); // Timeout estimator (after db — uses task_metrics table) @@ -475,7 +509,11 @@ export async function startCommand(): Promise { }); const shellTools = createShellTools(shell); allTools.push(...shellTools); - logger.info('Shell engine initialized', { allowPatterns: shellAllowPatterns.length }, { emoji: '✅' }); + logger.info( + 'Shell engine initialized', + { allowPatterns: shellAllowPatterns.length }, + { emoji: '✅' }, + ); // execute_code tool — sandboxed code execution for agents const executeCodeTool: Tool = { @@ -488,7 +526,11 @@ export async function startCommand(): Promise { type: 'object', properties: { code: { type: 'string', description: 'The JavaScript/TypeScript code to execute' }, - input: { type: 'string', description: 'Optional data to pass as the `input` variable in the sandbox (use JSON string for complex data)' }, + input: { + type: 'string', + description: + 'Optional data to pass as the `input` variable in the sandbox (use JSON string for complex data)', + }, timeout: { type: 'number', description: 'Override timeout in ms (max 30s)' }, }, required: ['code'], @@ -500,9 +542,10 @@ export async function startCommand(): Promise { const timeout = args.timeout ? Math.min(Number(args.timeout), 30_000) : undefined; const input = args.input; - const result = input !== undefined - ? await sandbox.executeWithInput(code, input, { timeoutMs: timeout }) - : await sandbox.execute(code, { timeoutMs: timeout }); + const result = + input !== undefined + ? await sandbox.executeWithInput(code, input, { timeoutMs: timeout }) + : await sandbox.execute(code, { timeoutMs: timeout }); if (result.success) { return result.output || '(no output)'; @@ -555,7 +598,8 @@ export async function startCommand(): Promise { name: 'builtin_model_switch', description: 'Switch the built-in Ollama Cloud model. Available models: ' + - BUILTIN_MODEL_TAGS.join(', ') + '. ' + + BUILTIN_MODEL_TAGS.join(', ') + + '. ' + 'This updates the configuration and triggers a restart so the new model ' + 'takes effect. Always warn the user before calling this — they will ' + 'briefly lose connectivity during the restart.', @@ -573,10 +617,8 @@ export async function startCommand(): Promise { async execute(args) { const model = (args.model as string)?.trim(); - if (!BUILTIN_MODEL_TAGS.includes(model as typeof BUILTIN_MODEL_TAGS[number])) { - return ( - `Invalid model "${model}". Available built-in models: ${BUILTIN_MODEL_TAGS.join(', ')}` - ); + if (!BUILTIN_MODEL_TAGS.includes(model as (typeof BUILTIN_MODEL_TAGS)[number])) { + return `Invalid model "${model}". Available built-in models: ${BUILTIN_MODEL_TAGS.join(', ')}`; } if (model === providerModel) { @@ -606,7 +648,7 @@ export async function startCommand(): Promise { name: 'primary_model_list', description: 'List all installed provider plugins and show which one is the primary provider. ' + - 'Shows each provider\'s ID, name, and availability status. ' + + "Shows each provider's ID, name, and availability status. " + 'The built-in Ollama Cloud provider is always available as the fallback.', parameters: { type: 'object', @@ -621,13 +663,17 @@ export async function startCommand(): Promise { lines.push('Built-in Provider (always available as fallback):'); lines.push(` • ${defaultProvider.name} (${defaultProvider.id})`); lines.push(` Model: ${providerModel}`); - lines.push(` Status: ${currentPrimary ? 'standby (primary is active)' : 'active (default)'}`); + lines.push( + ` Status: ${currentPrimary ? 'standby (primary is active)' : 'active (default)'}`, + ); lines.push(''); if (pluginProviders.length === 0) { lines.push('No provider plugins installed.'); lines.push(''); - lines.push('To install a provider plugin, add it to plugins.enabled in the config and restart.'); + lines.push( + 'To install a provider plugin, add it to plugins.enabled in the config and restart.', + ); } else { lines.push(`Installed Provider Plugins (${pluginProviders.length}):`); @@ -674,7 +720,8 @@ export async function startCommand(): Promise { properties: { provider_id: { type: 'string', - description: 'The ID of the installed provider plugin to set as primary (from primary_model_list)', + description: + 'The ID of the installed provider plugin to set as primary (from primary_model_list)', }, }, required: ['provider_id'], @@ -701,9 +748,7 @@ export async function startCommand(): Promise { const available = pluginProviders.map((pp) => pp.id).join(', '); return ( `Provider "${providerId}" is not installed. ` + - (available - ? `Available providers: ${available}` - : 'No provider plugins are installed.') + (available ? `Available providers: ${available}` : 'No provider plugins are installed.') ); } @@ -730,7 +775,9 @@ export async function startCommand(): Promise { apiKeyRef: undefined, }); - logger.info(`Primary provider set: ${target.name} (${target.id})`, undefined, { emoji: '🔄' }); + logger.info(`Primary provider set: ${target.name} (${target.id})`, undefined, { + emoji: '🔄', + }); // Trigger restart setTimeout(() => { @@ -810,9 +857,7 @@ export async function startCommand(): Promise { const allTasks = db.getUserBackgroundTasks('default-user'); let suspended = 0; for (const agent of activeAgents) { - const hasRunning = allTasks.some( - (t) => t.agentId === agent.id && t.status === 'running', - ); + const hasRunning = allTasks.some((t) => t.agentId === agent.id && t.status === 'running'); if (!hasRunning) { delegationResult.lifecycle.suspend(agent.id); suspended++; @@ -934,9 +979,9 @@ export async function startCommand(): Promise { await queue.enqueue('pulse:proactive', async () => { await agentLoop( '[SYSTEM: This is a proactive check-in. Review your memory, recent logs, and any pending tasks. ' + - 'Prepare a brief, useful status update or helpful suggestion for your owner. ' + - 'Think about: what tasks are pending, what you learned recently, and what might be helpful to share. ' + - 'Save any insights to your daily log. Do NOT respond conversationally — just update your internal state.]', + 'Prepare a brief, useful status update or helpful suggestion for your owner. ' + + 'Think about: what tasks are pending, what you learned recently, and what might be helpful to share. ' + + 'Save any insights to your daily log. Do NOT respond conversationally — just update your internal state.]', 'pulse:proactive', context, ); @@ -995,25 +1040,29 @@ export async function startCommand(): Promise { if (needsBuild) { if (!existsSync(webRoot)) { - logger.warn(`Web UI source not found at ${webRoot} — skipping build`, undefined, { emoji: '⚠️' }); - } else { - logger.info('Web UI build needed — building now...', undefined, { emoji: '🔨' }); - try { - const buildResult = Bun.spawnSync([process.execPath, 'run', 'build'], { - cwd: webRoot, - stdio: ['ignore', 'pipe', 'pipe'], + logger.warn(`Web UI source not found at ${webRoot} — skipping build`, undefined, { + emoji: '⚠️', }); + } else { + logger.info('Web UI build needed — building now...', undefined, { emoji: '🔨' }); + try { + const buildResult = Bun.spawnSync([process.execPath, 'run', 'build'], { + cwd: webRoot, + stdio: ['ignore', 'pipe', 'pipe'], + }); - if (buildResult.exitCode === 0) { - logger.info('Web UI built successfully', undefined, { emoji: '✅' }); - } else { - const stderr = buildResult.stderr?.toString().trim(); - logger.warn('Web UI build failed — dashboard will show setup instructions', undefined, { emoji: '⚠️' }); - if (stderr) logger.warn(stderr); + if (buildResult.exitCode === 0) { + logger.info('Web UI built successfully', undefined, { emoji: '✅' }); + } else { + const stderr = buildResult.stderr?.toString().trim(); + logger.warn('Web UI build failed — dashboard will show setup instructions', undefined, { + emoji: '⚠️', + }); + if (stderr) logger.warn(stderr); + } + } catch (err) { + logger.warn('Could not build Web UI:', err, { emoji: '⚠️' }); } - } catch (err) { - logger.warn('Could not build Web UI:', err, { emoji: '⚠️' }); - } } } @@ -1035,8 +1084,7 @@ export async function startCommand(): Promise { onMessage: async (message: string, userId: string) => { // Update companion activity tracker on every user message touchCompanionActivity?.(); - const { provider, classification, failedOver } = - await orchestrator.routeWithHealth(message); + const { provider, classification, failedOver } = await orchestrator.routeWithHealth(message); logger.debug('Routed query', { tier: classification.tier, provider: provider.id, @@ -1044,9 +1092,7 @@ export async function startCommand(): Promise { failedOver, }); const routedContext = { ...context, provider }; - return await queue.enqueue(userId, () => - agentLoop(message, userId, routedContext), - ); + return await queue.enqueue(userId, () => agentLoop(message, userId, routedContext)); }, getBackgroundTasks: (userId: string) => { return delegationResult.background.getAll(userId); @@ -1058,17 +1104,14 @@ export async function startCommand(): Promise { onMessageStream: async (message: string, userId: string, callback: StreamCallback) => { // Update companion activity tracker on every user message touchCompanionActivity?.(); - const { provider, classification, failedOver } = - await orchestrator.routeWithHealth(message); + const { provider, classification, failedOver } = await orchestrator.routeWithHealth(message); logger.debug('Routed query (stream)', { tier: classification.tier, provider: provider.id, failedOver, }); const routedContext = { ...context, provider }; - await queue.enqueue(userId, () => - agentLoop(message, userId, routedContext, callback), - ); + await queue.enqueue(userId, () => agentLoop(message, userId, routedContext, callback)); }, }); @@ -1088,7 +1131,8 @@ export async function startCommand(): Promise { quietHoursStart: configManager.get('nudge.quietHoursStart'), quietHoursEnd: configManager.get('nudge.quietHoursEnd'), maxPerHour: configManager.get('nudge.maxPerHour') ?? 5, - suppressedCategories: (configManager.get('nudge.suppressedCategories') ?? []) as import('@tinyclaw/types').NudgeCategory[], + suppressedCategories: (configManager.get('nudge.suppressedCategories') ?? + []) as import('@tinyclaw/types').NudgeCategory[], }; const nudgeEngine = createNudgeEngine({ gateway, preferences: nudgePrefs }); @@ -1106,7 +1150,8 @@ export async function startCommand(): Promise { quietHoursStart: configManager.get('nudge.quietHoursStart'), quietHoursEnd: configManager.get('nudge.quietHoursEnd'), maxPerHour: configManager.get('nudge.maxPerHour') ?? 5, - suppressedCategories: (configManager.get('nudge.suppressedCategories') ?? []) as import('@tinyclaw/types').NudgeCategory[], + suppressedCategories: (configManager.get('nudge.suppressedCategories') ?? + []) as import('@tinyclaw/types').NudgeCategory[], }); }); @@ -1153,7 +1198,8 @@ export async function startCommand(): Promise { // Deduplicate: skip if a pending nudge already exists for this version const pending = nudgeEngine.pending(); const alreadyQueued = pending.some( - (n) => n.category === 'software_update' && n.metadata?.latestVersion === updateInfo.latest, + (n) => + n.category === 'software_update' && n.metadata?.latestVersion === updateInfo.latest, ); if (alreadyQueued) return; @@ -1188,9 +1234,7 @@ export async function startCommand(): Promise { enqueue: async (userId: string, message: string) => { const { provider } = await orchestrator.routeWithHealth(message); const routedContext = { ...context, provider }; - return queue.enqueue(userId, () => - agentLoop(message, userId, routedContext), - ); + return queue.enqueue(userId, () => agentLoop(message, userId, routedContext)); }, agentContext: context, secrets: secretsManager, @@ -1208,7 +1252,7 @@ export async function startCommand(): Promise { gateway.register(channel.channelPrefix, { name: channel.name, async send(userId, message) { - await channel.sendToUser!(userId, message); + await channel.sendToUser?.(userId, message); }, }); } @@ -1222,7 +1266,9 @@ export async function startCommand(): Promise { logger.log(''); logger.log('Tiny Claw is ready!', undefined, { emoji: '🎉' }); logger.info(`API server: http://localhost:${port}`, undefined, { emoji: '🌐' }); - logger.debug('Web UI: Run "bun run dev:ui" then open http://localhost:5173', undefined, { emoji: '🔧' }); + logger.debug('Web UI: Run "bun run dev:ui" then open http://localhost:5173', undefined, { + emoji: '🔧', + }); logger.log(''); // --- Graceful shutdown ------------------------------------------------ diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 36b2b52..1ebcaad 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -16,25 +16,29 @@ */ import { logger } from '@tinyclaw/logger'; -import { showBanner, getVersion } from './ui/banner.js'; +import { getVersion, showBanner } from './ui/banner.js'; import { theme } from './ui/theme.js'; // ── Help text ────────────────────────────────────────────────────────── function showHelp(): void { showBanner(); - console.log(' ' + theme.label('Usage')); + console.log(` ${theme.label('Usage')}`); console.log(` ${theme.cmd('tinyclaw')} ${theme.dim('')}`); console.log(); - console.log(' ' + theme.label('Commands')); - console.log(` ${theme.cmd('setup')} Interactive setup wizard (use --web for browser onboarding)`); + console.log(` ${theme.label('Commands')}`); + console.log( + ` ${theme.cmd('setup')} Interactive setup wizard (use --web for browser onboarding)`, + ); console.log(` ${theme.cmd('start')} Start the Tiny Claw agent`); console.log(` ${theme.cmd('config')} Manage models, providers, and settings`); console.log(` ${theme.cmd('seed')} Show your Tiny Claw's soul seed`); console.log(` ${theme.cmd('backup')} Export or import a .tinyclaw backup archive`); - console.log(` ${theme.cmd('purge')} Wipe all data for a fresh install (--force to include secrets)`); + console.log( + ` ${theme.cmd('purge')} Wipe all data for a fresh install (--force to include secrets)`, + ); console.log(); - console.log(' ' + theme.label('Options')); + console.log(` ${theme.label('Options')}`); console.log(` ${theme.dim('--verbose')} Show debug-level logs during start`); console.log(` ${theme.dim('--version, -v')} Show version number`); console.log(` ${theme.dim('--help, -h')} Show this help message`); @@ -118,9 +122,7 @@ async function main(): Promise { } default: { - console.log( - theme.error(` Unknown command: ${command}`) - ); + console.log(theme.error(` Unknown command: ${command}`)); console.log(); showHelp(); process.exit(1); diff --git a/src/cli/src/supervisor.ts b/src/cli/src/supervisor.ts index 82a6c71..af6ea3e 100644 --- a/src/cli/src/supervisor.ts +++ b/src/cli/src/supervisor.ts @@ -16,7 +16,7 @@ * child exits N → supervisor exits N (error passthrough) */ -import { spawn } from 'child_process'; +import { spawn } from 'node:child_process'; import { logger } from '@tinyclaw/logger'; /** @@ -58,16 +58,13 @@ export async function supervisedStart(): Promise { restartTimestamps.push(now); // Prune timestamps outside the window - while ( - restartTimestamps.length > 0 && - restartTimestamps[0]! < now - RAPID_RESTART_WINDOW_MS - ) { + while (restartTimestamps.length > 0 && restartTimestamps[0]! < now - RAPID_RESTART_WINDOW_MS) { restartTimestamps.shift(); } if (restartTimestamps.length > MAX_RAPID_RESTARTS) { logger.error( - `Agent restarted ${MAX_RAPID_RESTARTS} times within ${RAPID_RESTART_WINDOW_MS / 1000}s — aborting to prevent crash loop.` + `Agent restarted ${MAX_RAPID_RESTARTS} times within ${RAPID_RESTART_WINDOW_MS / 1000}s — aborting to prevent crash loop.`, ); process.exit(1); } diff --git a/src/cli/src/ui/banner.ts b/src/cli/src/ui/banner.ts index 0b7effe..5936d77 100644 --- a/src/cli/src/ui/banner.ts +++ b/src/cli/src/ui/banner.ts @@ -45,12 +45,12 @@ try { * Print the branded banner to stdout */ export function showBanner(): void { - console.log(theme.brand('\n' + LOGO)); + console.log(theme.brand(`\n${LOGO}`)); console.log( - ` ${theme.dim('v' + getVersion())} ${theme.dim('—')} ${theme.dim('Your Personal Autonomous AI Companion 🐜')}` + ` ${theme.dim(`v${getVersion()}`)} ${theme.dim('—')} ${theme.dim('Your Personal Autonomous AI Companion 🐜')}`, ); console.log( - ` ${theme.dim('The original Tiny Claw — an alternative to OpenClaw, written from scratch 🐜')}` + ` ${theme.dim('The original Tiny Claw — an alternative to OpenClaw, written from scratch 🐜')}`, ); console.log(); } diff --git a/src/cli/tests/cli-router.test.ts b/src/cli/tests/cli-router.test.ts index 0e64107..b4dd76c 100644 --- a/src/cli/tests/cli-router.test.ts +++ b/src/cli/tests/cli-router.test.ts @@ -6,7 +6,7 @@ */ import { describe, expect, test } from 'bun:test'; -import { resolve } from 'path'; +import { resolve } from 'node:path'; const CLI_ENTRY = resolve(__dirname, '../src/index.ts'); @@ -36,11 +36,7 @@ async function runCLI( ); const [stdout, stderr, exitCode] = await Promise.race([ - Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]), + Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]), timeoutPromise, ]); diff --git a/src/cli/tests/commands/backup.test.ts b/src/cli/tests/commands/backup.test.ts index b3460eb..c20b0cf 100644 --- a/src/cli/tests/commands/backup.test.ts +++ b/src/cli/tests/commands/backup.test.ts @@ -32,7 +32,7 @@ function makeTarHeader( header.write('0000000\0', 116, 8, 'utf-8'); // size (124-135) — octal, 11 digits + NUL - header.write(size.toString(8).padStart(11, '0') + '\0', 124, 12, 'utf-8'); + header.write(`${size.toString(8).padStart(11, '0')}\0`, 124, 12, 'utf-8'); // mtime (136-147) header.write('00000000000\0', 136, 12, 'utf-8'); @@ -58,7 +58,7 @@ function makeTarHeader( for (let i = 0; i < TAR_BLOCK; i++) { checksum += header[i]; } - const checksumStr = checksum.toString(8).padStart(6, '0') + '\0 '; + const checksumStr = `${checksum.toString(8).padStart(6, '0')}\0 `; header.write(checksumStr, 148, 8, 'utf-8'); return header; @@ -127,7 +127,8 @@ describe('parseTar', () => { const hardlinkHeader = makeTarHeader('hard', '1', 0, 'file.txt'); const tar = Buffer.concat([ - fileHeader, fileData, + fileHeader, + fileData, dirHeader, symlinkHeader, hardlinkHeader, diff --git a/src/cli/tests/commands/setup.test.ts b/src/cli/tests/commands/setup.test.ts index ff32425..57b1bd3 100644 --- a/src/cli/tests/commands/setup.test.ts +++ b/src/cli/tests/commands/setup.test.ts @@ -7,7 +7,7 @@ * filesystem side-effects. */ -import { afterEach, beforeEach, describe, expect, test, mock, jest } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; // ── Mock @clack/prompts ────────────────────────────────────────────── @@ -91,7 +91,9 @@ const mockIsAvailable = mock(() => Promise.resolve(true)); const mockGenerateTotpSecret = mock(() => 'JBSWY3DPEHPK3PXP'); const mockCreateTotpUri = mock(() => 'otpauth://totp/TinyClaw?secret=JBSWY3DPEHPK3PXP'); const mockVerifyTotpCode = mock(() => Promise.resolve(true)); -const mockGenerateBackupCodes = mock(() => Array.from({ length: 10 }, (_, i) => `BACKUP${String(i).padStart(2, '0')}`)); +const mockGenerateBackupCodes = mock(() => + Array.from({ length: 10 }, (_, i) => `BACKUP${String(i).padStart(2, '0')}`), +); const mockGenerateRecoveryToken = mock(() => 'RECOVERYTOKEN'.repeat(5)); const mockSha256 = mock(() => Promise.resolve('abc123')); @@ -151,7 +153,7 @@ mock.module('child_process', () => ({ const mockParseSeed = mock((input: unknown) => { const n = Number(input); - if (!input || isNaN(n)) return 42; + if (!input || Number.isNaN(n)) return 42; return n; }); const mockGenerateRandomSeed = mock(() => 42); @@ -241,12 +243,14 @@ beforeEach(() => { mockGenerateTotpSecret.mockImplementation(() => 'JBSWY3DPEHPK3PXP'); mockCreateTotpUri.mockImplementation(() => 'otpauth://totp/TinyClaw?secret=JBSWY3DPEHPK3PXP'); mockVerifyTotpCode.mockImplementation(() => Promise.resolve(true)); - mockGenerateBackupCodes.mockImplementation(() => Array.from({ length: 10 }, (_, i) => `BACKUP${String(i).padStart(2, '0')}`)); + mockGenerateBackupCodes.mockImplementation(() => + Array.from({ length: 10 }, (_, i) => `BACKUP${String(i).padStart(2, '0')}`), + ); mockGenerateRecoveryToken.mockImplementation(() => 'RECOVERYTOKEN'.repeat(5)); mockSha256.mockImplementation(() => Promise.resolve('abc123')); mockParseSeed.mockImplementation((input: unknown) => { const n = Number(input); - if (!input || isNaN(n)) return 42; + if (!input || Number.isNaN(n)) return 42; return n; }); mockGenerateRandomSeed.mockImplementation(() => 42); @@ -281,10 +285,7 @@ describe('setupCommand', () => { test('stores the API key in secrets manager', async () => { await setupCommand(); - expect(mockSecretsStore).toHaveBeenCalledWith( - 'provider.ollama.apiKey', - 'test-api-key', - ); + expect(mockSecretsStore).toHaveBeenCalledWith('provider.ollama.apiKey', 'test-api-key'); }); test('persists provider config with default model', async () => { @@ -325,14 +326,12 @@ describe('setupCommand — existing configuration', () => { test('prompts to reconfigure when already configured', async () => { mockSecretsCheck.mockImplementation(() => Promise.resolve(true)); // Return a saved seed so the setup detects "fully configured" - mockConfigGet.mockImplementation((key: string) => - key === 'heartware.seed' ? 42 : undefined, - ); + mockConfigGet.mockImplementation((key: string) => (key === 'heartware.seed' ? 42 : undefined)); // confirm calls: risk acceptance → true, reconfigure → false (decline) let callCount = 0; mockConfirm.mockImplementation(() => { callCount++; - return callCount === 1 ? true : false; + return callCount === 1; }); await setupCommand(); @@ -341,13 +340,11 @@ describe('setupCommand — existing configuration', () => { test('skips setup when user declines reconfiguration', async () => { mockSecretsCheck.mockImplementation(() => Promise.resolve(true)); - mockConfigGet.mockImplementation((key: string) => - key === 'heartware.seed' ? 42 : undefined, - ); + mockConfigGet.mockImplementation((key: string) => (key === 'heartware.seed' ? 42 : undefined)); let callCount = 0; mockConfirm.mockImplementation(() => { callCount++; - return callCount === 1 ? true : false; + return callCount === 1; }); await setupCommand(); @@ -357,9 +354,7 @@ describe('setupCommand — existing configuration', () => { test('proceeds with setup when user confirms reconfiguration', async () => { mockSecretsCheck.mockImplementation(() => Promise.resolve(true)); - mockConfigGet.mockImplementation((key: string) => - key === 'heartware.seed' ? 42 : undefined, - ); + mockConfigGet.mockImplementation((key: string) => (key === 'heartware.seed' ? 42 : undefined)); mockConfirm.mockImplementation(() => true); await setupCommand(); @@ -376,9 +371,7 @@ describe('setupCommand — provider verification', () => { }); test('handles verification error gracefully', async () => { - mockIsAvailable.mockImplementation(() => - Promise.reject(new Error('connection refused')), - ); + mockIsAvailable.mockImplementation(() => Promise.reject(new Error('connection refused'))); await expect(setupCommand()).resolves.toBeUndefined(); expect(mockSpinnerStop).toHaveBeenCalled(); diff --git a/src/cli/tests/commands/start.test.ts b/src/cli/tests/commands/start.test.ts index 9f79369..80a3ad8 100644 --- a/src/cli/tests/commands/start.test.ts +++ b/src/cli/tests/commands/start.test.ts @@ -6,7 +6,7 @@ * to test control-flow logic in isolation. */ -import { afterEach, beforeAll, beforeEach, describe, expect, test, mock, jest } from 'bun:test'; +import { afterEach, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'; // ── Mock @tinyclaw/secrets ─────────────────────────────────────────── @@ -118,10 +118,18 @@ mock.module('@tinyclaw/queue', () => ({ mock.module('@tinyclaw/logger', () => ({ logger: { - log: mock((...args: any[]) => { console.log(...args); }), - info: mock((...args: any[]) => { console.log(...args); }), - warn: mock((...args: any[]) => { console.log(...args); }), - error: mock((...args: any[]) => { console.log(...args); }), + log: mock((...args: any[]) => { + console.log(...args); + }), + info: mock((...args: any[]) => { + console.log(...args); + }), + warn: mock((...args: any[]) => { + console.log(...args); + }), + error: mock((...args: any[]) => { + console.log(...args); + }), debug: mock(() => {}), }, setLogMode: mock(() => {}), @@ -154,7 +162,7 @@ mock.module('@tinyclaw/heartware', () => ({ loadShieldContent: mock(() => Promise.resolve('')), parseSeed: mock((input: unknown) => { const n = Number(input); - return isNaN(n) ? undefined : n; + return Number.isNaN(n) ? undefined : n; }), })); @@ -361,7 +369,6 @@ describe('startCommand', () => { await startCommand(); expect(mockGetStats).toHaveBeenCalled(); }); - }); describe('startCommand — missing API key', () => { diff --git a/src/cli/tests/config.test.ts b/src/cli/tests/config.test.ts index fb2f736..ba3303f 100644 --- a/src/cli/tests/config.test.ts +++ b/src/cli/tests/config.test.ts @@ -5,10 +5,10 @@ * and validates stdout / exit codes. */ -import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; -import { resolve, join } from 'path'; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; const CLI_ENTRY = resolve(__dirname, '../src/index.ts'); @@ -16,12 +16,19 @@ const CLI_ENTRY = resolve(__dirname, '../src/index.ts'); let tempDataDir: string; beforeEach(() => { - tempDataDir = join(tmpdir(), `tinyclaw-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + tempDataDir = join( + tmpdir(), + `tinyclaw-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); mkdirSync(tempDataDir, { recursive: true }); }); afterEach(() => { - try { rmSync(tempDataDir, { recursive: true, force: true }); } catch { /* best effort */ } + try { + rmSync(tempDataDir, { recursive: true, force: true }); + } catch { + /* best effort */ + } }); /** @@ -53,11 +60,7 @@ async function runCLI( ); const [stdout, stderr, exitCode] = await Promise.race([ - Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]), + Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]), timeoutPromise, ]); diff --git a/src/cli/tests/purge.test.ts b/src/cli/tests/purge.test.ts index 416bacc..18654b1 100644 --- a/src/cli/tests/purge.test.ts +++ b/src/cli/tests/purge.test.ts @@ -7,10 +7,10 @@ * Interactive confirmation tests use --yes to bypass the prompt. */ -import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; -import { resolve, join } from 'path'; -import { mkdirSync, writeFileSync, existsSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; const CLI_ENTRY = resolve(__dirname, '../src/index.ts'); @@ -53,11 +53,7 @@ async function runPurge( ); const [stdout, stderr, exitCode] = await Promise.race([ - Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - proc.exited, - ]), + Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]), timeoutPromise, ]); @@ -202,4 +198,3 @@ describe('tinyclaw purge --force', () => { expect(stdout).toContain('Secrets were deleted'); }); }); - diff --git a/src/cli/tests/ui/banner.test.ts b/src/cli/tests/ui/banner.test.ts index 2c2eab0..5ece57c 100644 --- a/src/cli/tests/ui/banner.test.ts +++ b/src/cli/tests/ui/banner.test.ts @@ -4,7 +4,7 @@ * Validates getVersion() and showBanner() output. */ -import { afterEach, beforeEach, describe, expect, test, jest } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { getVersion, showBanner } from '../../src/ui/banner.js'; describe('getVersion', () => { diff --git a/src/cli/tests/ui/theme.test.ts b/src/cli/tests/ui/theme.test.ts index a1f4641..ec88aae 100644 --- a/src/cli/tests/ui/theme.test.ts +++ b/src/cli/tests/ui/theme.test.ts @@ -9,16 +9,7 @@ import { describe, expect, test } from 'bun:test'; import { theme } from '../../src/ui/theme.js'; describe('theme', () => { - const allHelpers = [ - 'brand', - 'success', - 'warn', - 'error', - 'dim', - 'bold', - 'cmd', - 'label', - ] as const; + const allHelpers = ['brand', 'success', 'warn', 'error', 'dim', 'bold', 'cmd', 'label'] as const; test('exports all expected helpers', () => { for (const name of allHelpers) { diff --git a/src/landing/src/main.js b/src/landing/src/main.js index 8a8e537..7a687ca 100644 --- a/src/landing/src/main.js +++ b/src/landing/src/main.js @@ -1,11 +1,11 @@ -import './app.css' -import { mount } from 'svelte' -import App from './App.svelte' +import './app.css'; +import { mount } from 'svelte'; +import App from './App.svelte'; -const target = document.getElementById('app') +const target = document.getElementById('app'); if (!target) { - throw new Error('Landing page failed to find #app root element.') + throw new Error('Landing page failed to find #app root element.'); } -mount(App, { target }) +mount(App, { target }); diff --git a/src/landing/vite.config.ts b/src/landing/vite.config.ts index 2db8baf..eb0c247 100644 --- a/src/landing/vite.config.ts +++ b/src/landing/vite.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from 'vite' -import { svelte } from '@sveltejs/vite-plugin-svelte' -import tailwindcss from '@tailwindcss/vite' -import { resolve } from 'path' +import { resolve } from 'node:path'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; export default defineConfig({ plugins: [svelte(), tailwindcss()], @@ -15,4 +15,4 @@ export default defineConfig({ server: { port: 5174, }, -}) +}); diff --git a/src/web/src/hatching-scene.js b/src/web/src/hatching-scene.js index 4b0730f..6367369 100644 --- a/src/web/src/hatching-scene.js +++ b/src/web/src/hatching-scene.js @@ -17,459 +17,489 @@ const CFG = { fadeInDur: 1.0, antEnterEnd: 2.0, eggCarrierEnter: 2.0, - exitDelay: 1.5, // seconds after egg placed before ants exit - wobbleDelay: 4.0, // seconds after egg placed before wobble starts - crackDelay: 6.0, // seconds after egg placed before crack - revealDelay: 7.0, // seconds after egg placed before card reveal -} + exitDelay: 1.5, // seconds after egg placed before ants exit + wobbleDelay: 4.0, // seconds after egg placed before wobble starts + crackDelay: 6.0, // seconds after egg placed before crack + revealDelay: 7.0, // seconds after egg placed before card reveal +}; // ─── Utilities ─────────────────────────────────────────────── function seededRng(seed) { - let s = seed | 0 || 1 - return () => { s = (s * 16807) % 2147483647; return (s - 1) / 2147483646 } + let s = seed | 0 || 1; + return () => { + s = (s * 16807) % 2147483647; + return (s - 1) / 2147483646; + }; } -function lerp(a, b, t) { return a + (b - a) * t } -function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) } +function lerp(a, b, t) { + return a + (b - a) * t; +} +function clamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)); +} function angleDiff(a, b) { - let d = b - a - while (d > Math.PI) d -= Math.PI * 2 - while (d < -Math.PI) d += Math.PI * 2 - return d + let d = b - a; + while (d > Math.PI) d -= Math.PI * 2; + while (d < -Math.PI) d += Math.PI * 2; + return d; } -function dist(x1, y1, x2, y2) { return Math.hypot(x2 - x1, y2 - y1) } +function dist(x1, y1, x2, y2) { + return Math.hypot(x2 - x1, y2 - y1); +} function randomEdgePoint(rng, w, h, margin = 60) { - const edge = Math.floor(rng() * 4) // 0=top 1=right 2=bottom 3=left + const edge = Math.floor(rng() * 4); // 0=top 1=right 2=bottom 3=left switch (edge) { - case 0: return { x: rng() * w, y: -margin, edge: 0 } - case 1: return { x: w + margin, y: rng() * h, edge: 1 } - case 2: return { x: rng() * w, y: h + margin, edge: 2 } - default: return { x: -margin, y: rng() * h, edge: 3 } + case 0: + return { x: rng() * w, y: -margin, edge: 0 }; + case 1: + return { x: w + margin, y: rng() * h, edge: 1 }; + case 2: + return { x: rng() * w, y: h + margin, edge: 2 }; + default: + return { x: -margin, y: rng() * h, edge: 3 }; } } function oppositeEdgePoint(rng, entryEdge, w, h, margin = 80) { // Pick exit on the opposite edge with some spread switch (entryEdge) { - case 0: return { x: margin + rng() * (w - margin * 2), y: h + margin } // top→bottom - case 1: return { x: -margin, y: margin + rng() * (h - margin * 2) } // right→left - case 2: return { x: margin + rng() * (w - margin * 2), y: -margin } // bottom→top - default: return { x: w + margin, y: margin + rng() * (h - margin * 2) } // left→right + case 0: + return { x: margin + rng() * (w - margin * 2), y: h + margin }; // top→bottom + case 1: + return { x: -margin, y: margin + rng() * (h - margin * 2) }; // right→left + case 2: + return { x: margin + rng() * (w - margin * 2), y: -margin }; // bottom→top + default: + return { x: w + margin, y: margin + rng() * (h - margin * 2) }; // left→right } } function exitPoint(x, y, w, h, margin = 80) { // Pick the nearest edge and go beyond it const dists = [ - { ex: x, ey: -margin }, // top - { ex: w + margin, ey: y }, // right - { ex: x, ey: h + margin }, // bottom - { ex: -margin, ey: y }, // left - ] - let best = dists[0], bestD = Infinity + { ex: x, ey: -margin }, // top + { ex: w + margin, ey: y }, // right + { ex: x, ey: h + margin }, // bottom + { ex: -margin, ey: y }, // left + ]; + let best = dists[0], + bestD = Infinity; for (const d of dists) { - const dd = dist(x, y, d.ex, d.ey) - if (dd < bestD) { bestD = dd; best = d } + const dd = dist(x, y, d.ex, d.ey); + if (dd < bestD) { + bestD = dd; + best = d; + } } - return best + return best; } // ─── Ground Texture ────────────────────────────────────────── function createGroundTexture(w, h) { - const c = document.createElement('canvas') - c.width = w; c.height = h - const ctx = c.getContext('2d') - const rng = seededRng(42) + const c = document.createElement('canvas'); + c.width = w; + c.height = h; + const ctx = c.getContext('2d'); + const rng = seededRng(42); // 1. Base gradient - const g = ctx.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, Math.max(w, h) * 0.7) - g.addColorStop(0, '#5a4233') - g.addColorStop(0.5, '#4a3525') - g.addColorStop(1, '#352519') - ctx.fillStyle = g - ctx.fillRect(0, 0, w, h) + const g = ctx.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, Math.max(w, h) * 0.7); + g.addColorStop(0, '#5a4233'); + g.addColorStop(0.5, '#4a3525'); + g.addColorStop(1, '#352519'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, w, h); // 2. Soil texture — tiny variable dots for (let i = 0; i < 10000; i++) { - const x = rng() * w, y = rng() * h - const sz = rng() * 2.2 + 0.4 - const b = rng() * 45 + 25 - ctx.fillStyle = `rgba(${b + 30},${b + 12},${b},${rng() * 0.28 + 0.08})` - ctx.fillRect(x, y, sz, sz) + const x = rng() * w, + y = rng() * h; + const sz = rng() * 2.2 + 0.4; + const b = rng() * 45 + 25; + ctx.fillStyle = `rgba(${b + 30},${b + 12},${b},${rng() * 0.28 + 0.08})`; + ctx.fillRect(x, y, sz, sz); } // 3. Pebbles / small stones for (let i = 0; i < 45; i++) { - const x = rng() * w, y = rng() * h - const rx = rng() * 5 + 2, ry = rng() * 3 + 1.5 - const a = rng() * Math.PI - const gr = rng() * 55 + 80 - ctx.fillStyle = `rgba(${gr},${gr - 8},${gr - 14},${rng() * 0.35 + 0.15})` - ctx.beginPath(); ctx.ellipse(x, y, rx, ry, a, 0, Math.PI * 2); ctx.fill() + const x = rng() * w, + y = rng() * h; + const rx = rng() * 5 + 2, + ry = rng() * 3 + 1.5; + const a = rng() * Math.PI; + const gr = rng() * 55 + 80; + ctx.fillStyle = `rgba(${gr},${gr - 8},${gr - 14},${rng() * 0.35 + 0.15})`; + ctx.beginPath(); + ctx.ellipse(x, y, rx, ry, a, 0, Math.PI * 2); + ctx.fill(); } // 4. Grass blades for (let i = 0; i < 60; i++) { - const x = rng() * w, y = rng() * h - const ht = rng() * 22 + 8 - const lean = (rng() - 0.5) * 16 - const green = rng() * 55 + 55 - ctx.strokeStyle = `rgba(${green - 18},${green + 28},${green - 22},${rng() * 0.45 + 0.25})` - ctx.lineWidth = rng() * 1.6 + 0.5 - ctx.beginPath() - ctx.moveTo(x, y) - ctx.quadraticCurveTo(x + lean * 0.5, y - ht * 0.6, x + lean, y - ht) - ctx.stroke() + const x = rng() * w, + y = rng() * h; + const ht = rng() * 22 + 8; + const lean = (rng() - 0.5) * 16; + const green = rng() * 55 + 55; + ctx.strokeStyle = `rgba(${green - 18},${green + 28},${green - 22},${rng() * 0.45 + 0.25})`; + ctx.lineWidth = rng() * 1.6 + 0.5; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.quadraticCurveTo(x + lean * 0.5, y - ht * 0.6, x + lean, y - ht); + ctx.stroke(); } // 5. Vignette - const v = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.28, w / 2, h / 2, Math.max(w, h) * 0.72) - v.addColorStop(0, 'rgba(0,0,0,0)') - v.addColorStop(1, 'rgba(0,0,0,0.45)') - ctx.fillStyle = v - ctx.fillRect(0, 0, w, h) - - return c + const v = ctx.createRadialGradient( + w / 2, + h / 2, + Math.min(w, h) * 0.28, + w / 2, + h / 2, + Math.max(w, h) * 0.72, + ); + v.addColorStop(0, 'rgba(0,0,0,0)'); + v.addColorStop(1, 'rgba(0,0,0,0.45)'); + ctx.fillStyle = v; + ctx.fillRect(0, 0, w, h); + + return c; } // ─── Ant ───────────────────────────────────────────────────── -const CARGO_TYPES = ['leaf', 'crumb', 'berry', 'seed'] +const CARGO_TYPES = ['leaf', 'crumb', 'berry', 'seed']; class Ant { constructor(id, cargo, x, y, w, h, rng, exitTarget) { - this.id = id - this.cargo = cargo // 'leaf' | 'crumb' | 'berry' | 'seed' | 'egg' | null - this.x = x - this.y = y - this.w = w - this.h = h - this.rng = rng - this.speed = CFG.antSpeed * (0.85 + rng() * 0.3) - this.walkPhase = rng() * Math.PI * 2 - this.opacity = 0 - this.placed = false + this.id = id; + this.cargo = cargo; // 'leaf' | 'crumb' | 'berry' | 'seed' | 'egg' | null + this.x = x; + this.y = y; + this.w = w; + this.h = h; + this.rng = rng; + this.speed = CFG.antSpeed * (0.85 + rng() * 0.3); + this.walkPhase = rng() * Math.PI * 2; + this.opacity = 0; + this.placed = false; // Natural wobble parameters for crossing ants - this.wobbleFreq = 0.6 + rng() * 0.8 // rad/s — gentle sway - this.wobbleAmp = 0.06 + rng() * 0.06 // radians — subtle + this.wobbleFreq = 0.6 + rng() * 0.8; // rad/s — gentle sway + this.wobbleAmp = 0.06 + rng() * 0.06; // radians — subtle // Detour state — occasional side-trips (only some ants detour) // Use id-based selection so it's truly varied (seeded RNG has correlated outputs) - this.willDetour = (id % 3 !== 0) // ~66% of ants detour, rest go straight - this.detourTimer = 0.5 + (((id * 31) % 17) / 17) * 5.0 // spread 0.5–5.5s based on id - this.detouring = false - this.detourPause = 0 // brief sniff-pause before veering - this.detourX = 0 - this.detourY = 0 - this.detoursDone = 0 - this.maxDetours = 1 + (id % 2) // alternating 1 or 2 detours - this.baseSpeed = this.speed // remember normal speed - this.exitX = exitTarget?.x ?? w / 2 // remember original exit - this.exitY = exitTarget?.y ?? h / 2 + this.willDetour = id % 3 !== 0; // ~66% of ants detour, rest go straight + this.detourTimer = 0.5 + (((id * 31) % 17) / 17) * 5.0; // spread 0.5–5.5s based on id + this.detouring = false; + this.detourPause = 0; // brief sniff-pause before veering + this.detourX = 0; + this.detourY = 0; + this.detoursDone = 0; + this.maxDetours = 1 + (id % 2); // alternating 1 or 2 detours + this.baseSpeed = this.speed; // remember normal speed + this.exitX = exitTarget?.x ?? w / 2; // remember original exit + this.exitY = exitTarget?.y ?? h / 2; if (cargo === 'egg') { // Egg carrier — target gets overridden in buildAnts - this.state = 'entering' - this.targetX = w / 2 - this.targetY = h / 2 - this.angle = Math.atan2(h / 2 - y, w / 2 - x) + this.state = 'entering'; + this.targetX = w / 2; + this.targetY = h / 2; + this.angle = Math.atan2(h / 2 - y, w / 2 - x); } else { // Regular ants — walk across the screen with small detours - this.state = 'crossing' - this.targetX = this.exitX - this.targetY = this.exitY - this.angle = Math.atan2(this.targetY - y, this.targetX - x) + (rng() - 0.5) * 0.15 + this.state = 'crossing'; + this.targetX = this.exitX; + this.targetY = this.exitY; + this.angle = Math.atan2(this.targetY - y, this.targetX - x) + (rng() - 0.5) * 0.15; } } pickDetour() { // Pause briefly (ant "notices" something), then veer off - this.detourPause = 0.3 + this.rng() * 0.3 + this.detourPause = 0.3 + this.rng() * 0.3; // Big perpendicular offset so the swerve is clearly visible - const perpAngle = this.angle + (this.rng() > 0.5 ? Math.PI / 2 : -Math.PI / 2) - const detourDist = 100 + this.rng() * 100 // 100-200px sideways - const forwardDist = 20 + this.rng() * 30 - this.detourX = this.x + Math.cos(perpAngle) * detourDist + Math.cos(this.angle) * forwardDist - this.detourY = this.y + Math.sin(perpAngle) * detourDist + Math.sin(this.angle) * forwardDist + const perpAngle = this.angle + (this.rng() > 0.5 ? Math.PI / 2 : -Math.PI / 2); + const detourDist = 100 + this.rng() * 100; // 100-200px sideways + const forwardDist = 20 + this.rng() * 30; + this.detourX = this.x + Math.cos(perpAngle) * detourDist + Math.cos(this.angle) * forwardDist; + this.detourY = this.y + Math.sin(perpAngle) * detourDist + Math.sin(this.angle) * forwardDist; // Clamp inside screen - this.detourX = clamp(this.detourX, 40, this.w - 40) - this.detourY = clamp(this.detourY, 40, this.h - 40) - this.targetX = this.detourX - this.targetY = this.detourY - this.detouring = true - this.detoursDone++ + this.detourX = clamp(this.detourX, 40, this.w - 40); + this.detourY = clamp(this.detourY, 40, this.h - 40); + this.targetX = this.detourX; + this.targetY = this.detourY; + this.detouring = true; + this.detoursDone++; } startExiting() { - this.state = 'exiting' - const ep = exitPoint(this.x, this.y, this.w, this.h) - this.targetX = ep.ex - this.targetY = ep.ey - this.speed = CFG.antSpeed * 1.1 + this.state = 'exiting'; + const ep = exitPoint(this.x, this.y, this.w, this.h); + this.targetX = ep.ex; + this.targetY = ep.ey; + this.speed = CFG.antSpeed * 1.1; } update(dt) { - if (this.state === 'gone') return + if (this.state === 'gone') return; // Fade in (quick, for all ants) if (this.opacity < 1) { - this.opacity = clamp(this.opacity + dt * 2.5, 0, 1) + this.opacity = clamp(this.opacity + dt * 2.5, 0, 1); } // Turn toward target - const targetAngle = Math.atan2(this.targetY - this.y, this.targetX - this.x) - const diff = angleDiff(this.angle, targetAngle) + const targetAngle = Math.atan2(this.targetY - this.y, this.targetX - this.x); + const diff = angleDiff(this.angle, targetAngle); // Faster turning during detour so the swerve is sharp and visible - const turnRate = this.detouring ? CFG.antTurnRate * 3 : CFG.antTurnRate - this.angle += clamp(diff, -turnRate * dt, turnRate * dt) + const turnRate = this.detouring ? CFG.antTurnRate * 3 : CFG.antTurnRate; + this.angle += clamp(diff, -turnRate * dt, turnRate * dt); // Natural ant wobble — gentle periodic sway while walking if (this.state === 'crossing') { - this.angle += Math.sin(this.walkPhase * this.wobbleFreq) * this.wobbleAmp * dt + this.angle += Math.sin(this.walkPhase * this.wobbleFreq) * this.wobbleAmp * dt; // Detour countdown (only for ants that detour, up to maxDetours) if (this.willDetour && this.detoursDone < this.maxDetours) { - this.detourTimer -= dt + this.detourTimer -= dt; if (this.detourTimer <= 0 && !this.detouring) { - this.pickDetour() - this.detourTimer = 2 + this.rng() * 3 + this.pickDetour(); + this.detourTimer = 2 + this.rng() * 3; } } // Detour pause — ant briefly stops before veering if (this.detourPause > 0) { - this.detourPause -= dt - this.speed = this.baseSpeed * 0.15 // nearly stop - return // skip movement while pausing + this.detourPause -= dt; + this.speed = this.baseSpeed * 0.15; // nearly stop + return; // skip movement while pausing } // While detouring, move a bit slower (sniffing around) if (this.detouring) { - this.speed = this.baseSpeed * 0.65 + this.speed = this.baseSpeed * 0.65; } else { - this.speed = this.baseSpeed + this.speed = this.baseSpeed; } // Reached detour point — resume toward exit if (this.detouring && dist(this.x, this.y, this.targetX, this.targetY) < 30) { - this.detouring = false - this.targetX = this.exitX - this.targetY = this.exitY - this.speed = this.baseSpeed + this.detouring = false; + this.targetX = this.exitX; + this.targetY = this.exitY; + this.speed = this.baseSpeed; } } // Move - this.x += Math.cos(this.angle) * this.speed * dt - this.y += Math.sin(this.angle) * this.speed * dt + this.x += Math.cos(this.angle) * this.speed * dt; + this.y += Math.sin(this.angle) * this.speed * dt; // Walk cycle - this.walkPhase += this.speed * dt * 0.14 + this.walkPhase += this.speed * dt * 0.14; // State transitions if (this.state === 'crossing') { // Off-screen → done - if ( - this.x < -100 || this.x > this.w + 100 || - this.y < -100 || this.y > this.h + 100 - ) { - this.state = 'gone' + if (this.x < -100 || this.x > this.w + 100 || this.y < -100 || this.y > this.h + 100) { + this.state = 'gone'; } } else if (this.state === 'entering') { // Egg carrier heading to center if (dist(this.x, this.y, this.targetX, this.targetY) < 30) { - this.state = 'delivering' + this.state = 'delivering'; } } else if (this.state === 'delivering') { if (dist(this.x, this.y, this.targetX, this.targetY) < 20) { - this.state = 'placing' + this.state = 'placing'; } } else if (this.state === 'placing') { // Slow down, stop - this.speed = Math.max(this.speed - 80 * dt, 0) + this.speed = Math.max(this.speed - 80 * dt, 0); if (this.speed <= 0 && !this.placed) { - this.placed = true - this.cargo = null + this.placed = true; + this.cargo = null; } } else if (this.state === 'exiting') { // Walk off screen — no fade - if ( - this.x < -100 || this.x > this.w + 100 || - this.y < -100 || this.y > this.h + 100 - ) { - this.state = 'gone' + if (this.x < -100 || this.x > this.w + 100 || this.y < -100 || this.y > this.h + 100) { + this.state = 'gone'; } } } draw(ctx) { - if (this.state === 'gone' || this.opacity <= 0) return - ctx.save() - ctx.globalAlpha = this.opacity - ctx.translate(this.x, this.y) - ctx.rotate(this.angle) - - this._drawLegs(ctx) - this._drawBody(ctx) - this._drawAntennae(ctx) - this._drawMandibles(ctx) - if (this.cargo) this._drawCargo(ctx) - - ctx.restore() + if (this.state === 'gone' || this.opacity <= 0) return; + ctx.save(); + ctx.globalAlpha = this.opacity; + ctx.translate(this.x, this.y); + ctx.rotate(this.angle); + + this._drawLegs(ctx); + this._drawBody(ctx); + this._drawAntennae(ctx); + this._drawMandibles(ctx); + if (this.cargo) this._drawCargo(ctx); + + ctx.restore(); } _drawBody(ctx) { // Abdomen (back, largest) - ctx.fillStyle = '#1a0e06' - ctx.beginPath() - ctx.ellipse(-14, 0, 9, 7, 0, 0, Math.PI * 2) - ctx.fill() + ctx.fillStyle = '#1a0e06'; + ctx.beginPath(); + ctx.ellipse(-14, 0, 9, 7, 0, 0, Math.PI * 2); + ctx.fill(); // Slight highlight - ctx.fillStyle = 'rgba(90,60,30,0.18)' - ctx.beginPath() - ctx.ellipse(-15, -2, 5, 3, -0.3, 0, Math.PI * 2) - ctx.fill() + ctx.fillStyle = 'rgba(90,60,30,0.18)'; + ctx.beginPath(); + ctx.ellipse(-15, -2, 5, 3, -0.3, 0, Math.PI * 2); + ctx.fill(); // Petiole (narrow waist) - ctx.fillStyle = '#1a0e06' - ctx.beginPath() - ctx.arc(-4, 0, 2.5, 0, Math.PI * 2) - ctx.fill() + ctx.fillStyle = '#1a0e06'; + ctx.beginPath(); + ctx.arc(-4, 0, 2.5, 0, Math.PI * 2); + ctx.fill(); // Thorax - ctx.fillStyle = '#221308' - ctx.beginPath() - ctx.ellipse(2, 0, 6, 5, 0, 0, Math.PI * 2) - ctx.fill() + ctx.fillStyle = '#221308'; + ctx.beginPath(); + ctx.ellipse(2, 0, 6, 5, 0, 0, Math.PI * 2); + ctx.fill(); // Head - ctx.fillStyle = '#2a1810' - ctx.beginPath() - ctx.ellipse(12, 0, 5, 4.5, 0, 0, Math.PI * 2) - ctx.fill() + ctx.fillStyle = '#2a1810'; + ctx.beginPath(); + ctx.ellipse(12, 0, 5, 4.5, 0, 0, Math.PI * 2); + ctx.fill(); // Eyes — tiny dots - ctx.fillStyle = '#888' - ctx.beginPath() - ctx.arc(14, -2.5, 1, 0, Math.PI * 2) - ctx.arc(14, 2.5, 1, 0, Math.PI * 2) - ctx.fill() + ctx.fillStyle = '#888'; + ctx.beginPath(); + ctx.arc(14, -2.5, 1, 0, Math.PI * 2); + ctx.arc(14, 2.5, 1, 0, Math.PI * 2); + ctx.fill(); } _drawLegs(ctx) { - ctx.strokeStyle = '#2a1508' - ctx.lineWidth = 1.4 - ctx.lineCap = 'round' + ctx.strokeStyle = '#2a1508'; + ctx.lineWidth = 1.4; + ctx.lineCap = 'round'; const pairs = [ { x: 7, bw: 3.8 }, { x: -1, bw: 4.2 }, { x: -11, bw: 5.5 }, - ] + ]; pairs.forEach((p, i) => { - const ph = this.walkPhase + i * 2.1 - const swing = Math.sin(ph) * 5.5 + const ph = this.walkPhase + i * 2.1; + const swing = Math.sin(ph) * 5.5; for (const side of [-1, 1]) { - const sp = side === 1 ? swing : -swing - ctx.beginPath() - ctx.moveTo(p.x, side * p.bw) + const sp = side === 1 ? swing : -swing; + ctx.beginPath(); + ctx.moveTo(p.x, side * p.bw); // Knee - const kx = p.x + sp * 0.4 - const ky = side * (p.bw + 7) + const kx = p.x + sp * 0.4; + const ky = side * (p.bw + 7); // Foot - const fx = p.x + sp - const fy = side * (p.bw + 13) - ctx.lineTo(kx, ky) - ctx.lineTo(fx, fy) - ctx.stroke() + const fx = p.x + sp; + const fy = side * (p.bw + 13); + ctx.lineTo(kx, ky); + ctx.lineTo(fx, fy); + ctx.stroke(); } - }) + }); } _drawAntennae(ctx) { - ctx.strokeStyle = '#2a1810' - ctx.lineWidth = 1.2 - ctx.lineCap = 'round' - const wave = Math.sin(this.walkPhase * 0.7) * 2 + ctx.strokeStyle = '#2a1810'; + ctx.lineWidth = 1.2; + ctx.lineCap = 'round'; + const wave = Math.sin(this.walkPhase * 0.7) * 2; for (const side of [-1, 1]) { - ctx.beginPath() - ctx.moveTo(15, side * 2) - ctx.quadraticCurveTo(19, side * (5 + wave * side), 23, side * (8 + wave)) - ctx.stroke() + ctx.beginPath(); + ctx.moveTo(15, side * 2); + ctx.quadraticCurveTo(19, side * (5 + wave * side), 23, side * (8 + wave)); + ctx.stroke(); } } _drawMandibles(ctx) { // Tiny claws — the signature feature! - ctx.strokeStyle = '#3a2010' - ctx.lineWidth = 2 - ctx.lineCap = 'round' + ctx.strokeStyle = '#3a2010'; + ctx.lineWidth = 2; + ctx.lineCap = 'round'; for (const side of [-1, 1]) { - ctx.beginPath() - ctx.moveTo(16, side * 2) - ctx.quadraticCurveTo(20, side * 4.5, 22, side * 1.5) - ctx.stroke() + ctx.beginPath(); + ctx.moveTo(16, side * 2); + ctx.quadraticCurveTo(20, side * 4.5, 22, side * 1.5); + ctx.stroke(); } } _drawCargo(ctx) { - const cx = 21, cy = 0 + const cx = 21, + cy = 0; switch (this.cargo) { case 'leaf': - ctx.fillStyle = '#4a8d3a' - ctx.beginPath() - ctx.ellipse(cx, cy, 5, 3, 0.3, 0, Math.PI * 2) - ctx.fill() - ctx.strokeStyle = '#3a7d28' - ctx.lineWidth = 0.6 - ctx.beginPath() - ctx.moveTo(cx - 4, cy) - ctx.lineTo(cx + 4, cy) - ctx.stroke() - break + ctx.fillStyle = '#4a8d3a'; + ctx.beginPath(); + ctx.ellipse(cx, cy, 5, 3, 0.3, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#3a7d28'; + ctx.lineWidth = 0.6; + ctx.beginPath(); + ctx.moveTo(cx - 4, cy); + ctx.lineTo(cx + 4, cy); + ctx.stroke(); + break; case 'crumb': - ctx.fillStyle = '#c8a870' - ctx.beginPath() - ctx.arc(cx, cy, 3.2, 0, Math.PI * 2) - ctx.fill() - ctx.fillStyle = 'rgba(255,255,255,0.15)' - ctx.beginPath() - ctx.arc(cx - 1, cy - 1, 1.2, 0, Math.PI * 2) - ctx.fill() - break + ctx.fillStyle = '#c8a870'; + ctx.beginPath(); + ctx.arc(cx, cy, 3.2, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.15)'; + ctx.beginPath(); + ctx.arc(cx - 1, cy - 1, 1.2, 0, Math.PI * 2); + ctx.fill(); + break; case 'berry': - ctx.fillStyle = '#8b3a8b' - ctx.beginPath() - ctx.arc(cx, cy, 3, 0, Math.PI * 2) - ctx.fill() - ctx.fillStyle = 'rgba(255,255,255,0.25)' - ctx.beginPath() - ctx.arc(cx - 0.8, cy - 1, 1, 0, Math.PI * 2) - ctx.fill() - break + ctx.fillStyle = '#8b3a8b'; + ctx.beginPath(); + ctx.arc(cx, cy, 3, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.25)'; + ctx.beginPath(); + ctx.arc(cx - 0.8, cy - 1, 1, 0, Math.PI * 2); + ctx.fill(); + break; case 'seed': - ctx.fillStyle = '#c8a850' - ctx.beginPath() - ctx.ellipse(cx, cy, 4, 2.2, 0.2, 0, Math.PI * 2) - ctx.fill() - break + ctx.fillStyle = '#c8a850'; + ctx.beginPath(); + ctx.ellipse(cx, cy, 4, 2.2, 0.2, 0, Math.PI * 2); + ctx.fill(); + break; case 'egg': { // Egg held between mandibles - const eg = ctx.createRadialGradient(cx - 1, cy - 1, 0, cx, cy, 8) - eg.addColorStop(0, '#fffdf5') - eg.addColorStop(0.6, '#fff5e6') - eg.addColorStop(1, '#ffe0b2') - ctx.fillStyle = eg - ctx.beginPath() - ctx.ellipse(cx + 1, cy, 5, 7.5, 0, 0, Math.PI * 2) - ctx.fill() + const eg = ctx.createRadialGradient(cx - 1, cy - 1, 0, cx, cy, 8); + eg.addColorStop(0, '#fffdf5'); + eg.addColorStop(0.6, '#fff5e6'); + eg.addColorStop(1, '#ffe0b2'); + ctx.fillStyle = eg; + ctx.beginPath(); + ctx.ellipse(cx + 1, cy, 5, 7.5, 0, 0, Math.PI * 2); + ctx.fill(); // Highlight - ctx.fillStyle = 'rgba(255,255,255,0.3)' - ctx.beginPath() - ctx.ellipse(cx, cy - 2, 2.5, 3, -0.3, 0, Math.PI * 2) - ctx.fill() - break + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.beginPath(); + ctx.ellipse(cx, cy - 2, 2.5, 3, -0.3, 0, Math.PI * 2); + ctx.fill(); + break; } } } @@ -478,145 +508,148 @@ class Ant { // ─── Egg ───────────────────────────────────────────────────── class Egg { constructor(x, y) { - this.x = x - this.y = y - this.radius = CFG.eggRadius - this.rotation = 0 - this.wobbleAmount = 0 - this.crackProgress = 0 - this.cracked = false - this.opacity = 1 - this.scale = 0 - this.appearStart = 0 + this.x = x; + this.y = y; + this.radius = CFG.eggRadius; + this.rotation = 0; + this.wobbleAmount = 0; + this.crackProgress = 0; + this.cracked = false; + this.opacity = 1; + this.scale = 0; + this.appearStart = 0; } appear(time) { - this.appearStart = time + this.appearStart = time; } update(dt, elapsed, eggPlacedAt) { // Appear animation (scale in) if (this.appearStart > 0) { - const t = clamp((elapsed - this.appearStart) / 0.5, 0, 1) - this.scale = t < 0.6 ? lerp(0, 1.12, t / 0.6) : lerp(1.12, 1, (t - 0.6) / 0.4) + const t = clamp((elapsed - this.appearStart) / 0.5, 0, 1); + this.scale = t < 0.6 ? lerp(0, 1.12, t / 0.6) : lerp(1.12, 1, (t - 0.6) / 0.4); } - if (eggPlacedAt <= 0) return - const wobbleStart = eggPlacedAt + CFG.wobbleDelay - const crackStart = eggPlacedAt + CFG.crackDelay + if (eggPlacedAt <= 0) return; + const wobbleStart = eggPlacedAt + CFG.wobbleDelay; + const crackStart = eggPlacedAt + CFG.crackDelay; // Wobble phase if (elapsed >= wobbleStart && !this.cracked) { - const wt = clamp((elapsed - wobbleStart) / (crackStart - wobbleStart), 0, 1) - this.wobbleAmount = wt * wt * 18 // degrees, ramps up quadratically - const freq = 4 + wt * 14 - this.rotation = Math.sin(elapsed * freq) * this.wobbleAmount * (Math.PI / 180) + const wt = clamp((elapsed - wobbleStart) / (crackStart - wobbleStart), 0, 1); + this.wobbleAmount = wt * wt * 18; // degrees, ramps up quadratically + const freq = 4 + wt * 14; + this.rotation = Math.sin(elapsed * freq) * this.wobbleAmount * (Math.PI / 180); } // Crack phase if (elapsed >= crackStart && !this.cracked) { - const ct = clamp((elapsed - crackStart) / 0.8, 0, 1) - this.crackProgress = ct - if (ct >= 1) this.cracked = true + const ct = clamp((elapsed - crackStart) / 0.8, 0, 1); + this.crackProgress = ct; + if (ct >= 1) this.cracked = true; } // Fade after crack if (this.cracked) { - this.opacity = clamp(this.opacity - dt * 3, 0, 1) + this.opacity = clamp(this.opacity - dt * 3, 0, 1); } } draw(ctx) { - if (this.opacity <= 0) return - ctx.save() - ctx.globalAlpha = this.opacity - ctx.translate(this.x, this.y) - ctx.rotate(this.rotation) - const s = this.scale + if (this.opacity <= 0) return; + ctx.save(); + ctx.globalAlpha = this.opacity; + ctx.translate(this.x, this.y); + ctx.rotate(this.rotation); + const s = this.scale; // Shadow - ctx.fillStyle = 'rgba(0,0,0,0.22)' - ctx.beginPath() - ctx.ellipse(2 * s, 5 * s, this.radius * 0.75 * s, this.radius * 0.28 * s, 0, 0, Math.PI * 2) - ctx.fill() + ctx.fillStyle = 'rgba(0,0,0,0.22)'; + ctx.beginPath(); + ctx.ellipse(2 * s, 5 * s, this.radius * 0.75 * s, this.radius * 0.28 * s, 0, 0, Math.PI * 2); + ctx.fill(); // Main egg - const r = this.radius * s - const eg = ctx.createRadialGradient(-r * 0.18, -r * 0.2, 0, 0, 0, r * 1.2) - eg.addColorStop(0, '#fffdf8') - eg.addColorStop(0.45, '#fff5e6') - eg.addColorStop(1, '#f5d9a8') - ctx.fillStyle = eg - ctx.beginPath() - ctx.ellipse(0, 0, r * 0.7, r, 0, 0, Math.PI * 2) - ctx.fill() + const r = this.radius * s; + const eg = ctx.createRadialGradient(-r * 0.18, -r * 0.2, 0, 0, 0, r * 1.2); + eg.addColorStop(0, '#fffdf8'); + eg.addColorStop(0.45, '#fff5e6'); + eg.addColorStop(1, '#f5d9a8'); + ctx.fillStyle = eg; + ctx.beginPath(); + ctx.ellipse(0, 0, r * 0.7, r, 0, 0, Math.PI * 2); + ctx.fill(); // Subtle outline - ctx.strokeStyle = 'rgba(180,140,90,0.25)' - ctx.lineWidth = 1 - ctx.stroke() + ctx.strokeStyle = 'rgba(180,140,90,0.25)'; + ctx.lineWidth = 1; + ctx.stroke(); // Highlight - ctx.fillStyle = 'rgba(255,255,255,0.35)' - ctx.beginPath() - ctx.ellipse(-r * 0.15, -r * 0.3, r * 0.28, r * 0.42, -0.3, 0, Math.PI * 2) - ctx.fill() + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.beginPath(); + ctx.ellipse(-r * 0.15, -r * 0.3, r * 0.28, r * 0.42, -0.3, 0, Math.PI * 2); + ctx.fill(); // Crack lines if (this.crackProgress > 0) { - this._drawCracks(ctx, r, this.crackProgress) + this._drawCracks(ctx, r, this.crackProgress); } // Glow during wobble if (this.wobbleAmount > 5) { - const glowAlpha = clamp((this.wobbleAmount - 5) / 13, 0, 0.25) - const glow = ctx.createRadialGradient(0, 0, r * 0.5, 0, 0, r * 2.5) - glow.addColorStop(0, `rgba(255,220,150,${glowAlpha})`) - glow.addColorStop(1, 'rgba(255,220,150,0)') - ctx.fillStyle = glow - ctx.beginPath() - ctx.arc(0, 0, r * 2.5, 0, Math.PI * 2) - ctx.fill() + const glowAlpha = clamp((this.wobbleAmount - 5) / 13, 0, 0.25); + const glow = ctx.createRadialGradient(0, 0, r * 0.5, 0, 0, r * 2.5); + glow.addColorStop(0, `rgba(255,220,150,${glowAlpha})`); + glow.addColorStop(1, 'rgba(255,220,150,0)'); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(0, 0, r * 2.5, 0, Math.PI * 2); + ctx.fill(); } - ctx.restore() + ctx.restore(); } _drawCracks(ctx, r, progress) { - const rng = seededRng(777) - const numCracks = Math.floor(progress * 7) + 1 - ctx.strokeStyle = '#7a5d3d' - ctx.lineWidth = 1.8 + const rng = seededRng(777); + const numCracks = Math.floor(progress * 7) + 1; + ctx.strokeStyle = '#7a5d3d'; + ctx.lineWidth = 1.8; for (let i = 0; i < numCracks; i++) { - const startAngle = rng() * Math.PI * 2 - const startR = rng() * r * 0.25 - let cx = Math.cos(startAngle) * startR - let cy = Math.sin(startAngle) * startR - ctx.beginPath() - ctx.moveTo(cx, cy) - const segs = 2 + Math.floor(rng() * 3) + const startAngle = rng() * Math.PI * 2; + const startR = rng() * r * 0.25; + let cx = Math.cos(startAngle) * startR; + let cy = Math.sin(startAngle) * startR; + ctx.beginPath(); + ctx.moveTo(cx, cy); + const segs = 2 + Math.floor(rng() * 3); for (let j = 0; j < segs; j++) { - cx += (rng() - 0.5) * r * 0.5 * progress - cy += (rng() - 0.5) * r * 0.6 * progress - ctx.lineTo(cx, cy) + cx += (rng() - 0.5) * r * 0.5 * progress; + cy += (rng() - 0.5) * r * 0.6 * progress; + ctx.lineTo(cx, cy); } - ctx.stroke() + ctx.stroke(); } } } // ─── Particles ─────────────────────────────────────────────── class ParticleSystem { - constructor() { this.particles = [] } + constructor() { + this.particles = []; + } emit(x, y, count) { for (let i = 0; i < count; i++) { - const angle = Math.random() * Math.PI * 2 - const speed = 60 + Math.random() * 200 - const colors = ['#fffdf5', '#ffeedd', '#ffe0b2', '#ffcc80', '#fff8e1', '#d4a85a'] + const angle = Math.random() * Math.PI * 2; + const speed = 60 + Math.random() * 200; + const colors = ['#fffdf5', '#ffeedd', '#ffe0b2', '#ffcc80', '#fff8e1', '#d4a85a']; this.particles.push({ - x, y, + x, + y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 1, @@ -624,17 +657,18 @@ class ParticleSystem { size: 2 + Math.random() * 5, color: colors[Math.floor(Math.random() * colors.length)], gravity: 60 + Math.random() * 40, - }) + }); } } // Sparkle / glow particles (slow, drifting) emitGlow(x, y, count) { for (let i = 0; i < count; i++) { - const angle = Math.random() * Math.PI * 2 - const speed = 10 + Math.random() * 30 + const angle = Math.random() * Math.PI * 2; + const speed = 10 + Math.random() * 30; this.particles.push({ - x, y, + x, + y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 20, life: 1, @@ -642,350 +676,358 @@ class ParticleSystem { size: 1.5 + Math.random() * 3, color: `rgba(255,240,200,${0.4 + Math.random() * 0.4})`, gravity: -10, - }) + }); } } update(dt) { - this.particles = this.particles.filter(p => { - p.x += p.vx * dt - p.y += p.vy * dt - p.vy += p.gravity * dt - p.life -= p.decay * dt - return p.life > 0 - }) + this.particles = this.particles.filter((p) => { + p.x += p.vx * dt; + p.y += p.vy * dt; + p.vy += p.gravity * dt; + p.life -= p.decay * dt; + return p.life > 0; + }); } draw(ctx) { for (const p of this.particles) { - ctx.globalAlpha = p.life - ctx.fillStyle = p.color - ctx.beginPath() - ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2) - ctx.fill() + ctx.globalAlpha = p.life; + ctx.fillStyle = p.color; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2); + ctx.fill(); } - ctx.globalAlpha = 1 + ctx.globalAlpha = 1; } - get active() { return this.particles.length > 0 } + get active() { + return this.particles.length > 0; + } } // ─── Scene Controller ──────────────────────────────────────── export function createHatchingScene(canvas, callbacks = {}) { - const ctx = canvas.getContext('2d') - let dpr = 1 - let w = 0, h = 0 - let groundTex = null - let ants = [] - let egg = null - let eggCarrier = null - let particles = new ParticleSystem() - let animId = null - let startTs = 0 - let prevTs = 0 - let currentPhase = 'init' - let fadeIn = 0 - let sceneFadeOut = 0 - let emittedCrack = false - let rng = seededRng(99) - let destroyed = false + const ctx = canvas.getContext('2d'); + let dpr = 1; + let w = 0, + h = 0; + let groundTex = null; + let ants = []; + let egg = null; + let eggCarrier = null; + let particles = new ParticleSystem(); + let animId = null; + let startTs = 0; + let prevTs = 0; + let currentPhase = 'init'; + let fadeIn = 0; + let sceneFadeOut = 0; + let emittedCrack = false; + let rng = seededRng(99); + let destroyed = false; function setPhase(p) { if (p !== currentPhase) { - currentPhase = p - callbacks.onPhaseChange?.(p) + currentPhase = p; + callbacks.onPhaseChange?.(p); } } function resize() { - dpr = window.devicePixelRatio || 1 - w = canvas.clientWidth - h = canvas.clientHeight - canvas.width = w * dpr - canvas.height = h * dpr - ctx.setTransform(dpr, 0, 0, dpr, 0, 0) - groundTex = createGroundTexture(w * dpr, h * dpr) + dpr = window.devicePixelRatio || 1; + w = canvas.clientWidth; + h = canvas.clientHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + groundTex = createGroundTexture(w * dpr, h * dpr); } function buildAnts() { - ants = [] - rng = seededRng(99) + ants = []; + rng = seededRng(99); for (let i = 0; i < CFG.antCount; i++) { - const start = randomEdgePoint(rng, w, h) - const hasCargo = rng() > 0.35 - const cargo = hasCargo ? CARGO_TYPES[Math.floor(rng() * CARGO_TYPES.length)] : null - const exit = oppositeEdgePoint(seededRng(i * 53 + 3), start.edge, w, h) - const ant = new Ant(i, cargo, start.x, start.y, w, h, seededRng(i * 137 + 7), exit) - ants.push(ant) + const start = randomEdgePoint(rng, w, h); + const hasCargo = rng() > 0.35; + const cargo = hasCargo ? CARGO_TYPES[Math.floor(rng() * CARGO_TYPES.length)] : null; + const exit = oppositeEdgePoint(seededRng(i * 53 + 3), start.edge, w, h); + const ant = new Ant(i, cargo, start.x, start.y, w, h, seededRng(i * 137 + 7), exit); + ants.push(ant); } // Egg carrier — enters with the pack, heads straight for center - const eStart = randomEdgePoint(rng, w, h) - eggCarrier = new Ant(100, 'egg', eStart.x, eStart.y, w, h, seededRng(555)) - eggCarrier.opacity = 0 - eggCarrier.state = 'entering' - eggCarrier.targetX = w / 2 - eggCarrier.targetY = h / 2 + const eStart = randomEdgePoint(rng, w, h); + eggCarrier = new Ant(100, 'egg', eStart.x, eStart.y, w, h, seededRng(555)); + eggCarrier.opacity = 0; + eggCarrier.state = 'entering'; + eggCarrier.targetX = w / 2; + eggCarrier.targetY = h / 2; // Point directly at center from the start - eggCarrier.angle = Math.atan2(h / 2 - eStart.y, w / 2 - eStart.x) - eggCarrier.speed = CFG.antSpeed * 1.4 + eggCarrier.angle = Math.atan2(h / 2 - eStart.y, w / 2 - eStart.x); + eggCarrier.speed = CFG.antSpeed * 1.4; } // Track when egg was placed so we can time exits after it - let eggPlacedAt = 0 + let eggPlacedAt = 0; function updatePhase(elapsed) { // Phase machine if (elapsed < CFG.fadeInDur) { - setPhase('fadein') + setPhase('fadein'); } else if (!eggCarrier.placed) { // Still waiting for egg delivery — ants keep wandering if (currentPhase === 'init' || currentPhase === 'fadein') { - setPhase('wander') + setPhase('wander'); } - } else if (eggPlacedAt > 0 && (elapsed - eggPlacedAt) < CFG.exitDelay) { + } else if (eggPlacedAt > 0 && elapsed - eggPlacedAt < CFG.exitDelay) { // Just placed — brief pause before exits - if (currentPhase !== 'deliver') setPhase('deliver') + if (currentPhase !== 'deliver') setPhase('deliver'); } else if (currentPhase === 'deliver') { // After exitDelay — egg carrier exits - setPhase('exit') + setPhase('exit'); if (eggCarrier.state !== 'exiting' && eggCarrier.state !== 'gone') { - eggCarrier.startExiting() + eggCarrier.startExiting(); } } // All timing below is relative to egg placement - if (eggPlacedAt <= 0) return - const sincePlace = elapsed - eggPlacedAt + if (eggPlacedAt <= 0) return; + const sincePlace = elapsed - eggPlacedAt; // Wobble if (sincePlace >= CFG.wobbleDelay && (currentPhase === 'exit' || currentPhase === 'deliver')) { - setPhase('wobble') + setPhase('wobble'); } // Crack if (sincePlace >= CFG.crackDelay && currentPhase === 'wobble') { - setPhase('crack') + setPhase('crack'); } // Reveal if (sincePlace >= CFG.revealDelay && currentPhase === 'crack') { - setPhase('reveal') - callbacks.onReveal?.() + setPhase('reveal'); + callbacks.onReveal?.(); } } function update(dt, elapsed) { // Fade in - fadeIn = clamp(elapsed / CFG.fadeInDur, 0, 1) + fadeIn = clamp(elapsed / CFG.fadeInDur, 0, 1); // Scene fade out after crack - const absCrackTime = eggPlacedAt > 0 ? eggPlacedAt + CFG.crackDelay : Infinity + const absCrackTime = eggPlacedAt > 0 ? eggPlacedAt + CFG.crackDelay : Infinity; if (elapsed >= absCrackTime) { - sceneFadeOut = clamp((elapsed - absCrackTime) / 1.5, 0, 0.65) + sceneFadeOut = clamp((elapsed - absCrackTime) / 1.5, 0, 0.65); } // Update ants - for (const a of ants) a.update(dt) + for (const a of ants) a.update(dt); // Egg carrier if (elapsed >= CFG.eggCarrierEnter) { if (eggCarrier.opacity < 1 && eggCarrier.state === 'entering') { - eggCarrier.opacity = clamp(eggCarrier.opacity + dt * 1.5, 0, 1) + eggCarrier.opacity = clamp(eggCarrier.opacity + dt * 1.5, 0, 1); } - eggCarrier.update(dt) + eggCarrier.update(dt); // Place egg if (eggCarrier.placed && !egg) { - egg = new Egg(eggCarrier.x, eggCarrier.y) - egg.appear(elapsed) - eggPlacedAt = elapsed + egg = new Egg(eggCarrier.x, eggCarrier.y); + egg.appear(elapsed); + eggPlacedAt = elapsed; // Start carrier exiting - eggCarrier.state = 'exiting' - const ep = exitPoint(eggCarrier.x, eggCarrier.y, w, h) - eggCarrier.targetX = ep.ex - eggCarrier.targetY = ep.ey - eggCarrier.speed = CFG.antSpeed * 1.1 + eggCarrier.state = 'exiting'; + const ep = exitPoint(eggCarrier.x, eggCarrier.y, w, h); + eggCarrier.targetX = ep.ex; + eggCarrier.targetY = ep.ey; + eggCarrier.speed = CFG.antSpeed * 1.1; } } // Update egg - if (egg) egg.update(dt, elapsed, eggPlacedAt) + if (egg) egg.update(dt, elapsed, eggPlacedAt); // Particles - particles.update(dt) + particles.update(dt); // Absolute crack/wobble times (only valid after egg placed) - const absCrack = eggPlacedAt > 0 ? eggPlacedAt + CFG.crackDelay : Infinity - const absWobble = eggPlacedAt > 0 ? eggPlacedAt + CFG.wobbleDelay : Infinity + const absCrack = eggPlacedAt > 0 ? eggPlacedAt + CFG.crackDelay : Infinity; + const absWobble = eggPlacedAt > 0 ? eggPlacedAt + CFG.wobbleDelay : Infinity; // Emit crack burst if (elapsed >= absCrack && !emittedCrack && egg) { - particles.emit(egg.x, egg.y, 45) - particles.emitGlow(egg.x, egg.y, 20) - emittedCrack = true + particles.emit(egg.x, egg.y, 45); + particles.emitGlow(egg.x, egg.y, 20); + emittedCrack = true; } // Emit glow sparks during intense wobble if (egg && elapsed >= absWobble + 1 && elapsed < absCrack && Math.random() < 0.15) { - particles.emitGlow(egg.x + (Math.random() - 0.5) * 20, egg.y + (Math.random() - 0.5) * 20, 1) + particles.emitGlow(egg.x + (Math.random() - 0.5) * 20, egg.y + (Math.random() - 0.5) * 20, 1); } } function draw() { - ctx.clearRect(0, 0, w, h) + ctx.clearRect(0, 0, w, h); // Black base - ctx.fillStyle = '#050505' - ctx.fillRect(0, 0, w, h) + ctx.fillStyle = '#050505'; + ctx.fillRect(0, 0, w, h); // Ground (fade in) - ctx.globalAlpha = fadeIn - ctx.drawImage(groundTex, 0, 0, w, h) - ctx.globalAlpha = 1 + ctx.globalAlpha = fadeIn; + ctx.drawImage(groundTex, 0, 0, w, h); + ctx.globalAlpha = 1; // Ants - for (const a of ants) a.draw(ctx) - if (eggCarrier) eggCarrier.draw(ctx) + for (const a of ants) a.draw(ctx); + if (eggCarrier) eggCarrier.draw(ctx); // Egg - if (egg) egg.draw(ctx) + if (egg) egg.draw(ctx); // Particles - particles.draw(ctx) + particles.draw(ctx); // Scene darken overlay after crack if (sceneFadeOut > 0) { - ctx.fillStyle = `rgba(5,5,5,${sceneFadeOut})` - ctx.fillRect(0, 0, w, h) + ctx.fillStyle = `rgba(5,5,5,${sceneFadeOut})`; + ctx.fillRect(0, 0, w, h); } // Fade overlay at start if (fadeIn < 1) { - ctx.fillStyle = `rgba(5,5,5,${1 - fadeIn})` - ctx.fillRect(0, 0, w, h) + ctx.fillStyle = `rgba(5,5,5,${1 - fadeIn})`; + ctx.fillRect(0, 0, w, h); } } function loop(ts) { - if (destroyed || paused) return - if (!startTs) { startTs = ts; prevTs = ts } - const adjustedTs = ts - pausedTotal - const elapsed = (adjustedTs - startTs) / 1000 - const dt = Math.min((ts - prevTs) / 1000, 0.05) // cap delta - prevTs = ts + if (destroyed || paused) return; + if (!startTs) { + startTs = ts; + prevTs = ts; + } + const adjustedTs = ts - pausedTotal; + const elapsed = (adjustedTs - startTs) / 1000; + const dt = Math.min((ts - prevTs) / 1000, 0.05); // cap delta + prevTs = ts; - updatePhase(elapsed) - update(dt, elapsed) - draw() + updatePhase(elapsed); + update(dt, elapsed); + draw(); - animId = requestAnimationFrame(loop) + animId = requestAnimationFrame(loop); } function start() { - resize() - buildAnts() - window.addEventListener('resize', onResize) - animId = requestAnimationFrame(loop) + resize(); + buildAnts(); + window.addEventListener('resize', onResize); + animId = requestAnimationFrame(loop); } function clampTarget(a) { // Recompute exit point for the new canvas bounds - const ep = exitPoint(a.x, a.y, w, h) - a.exitX = ep.ex - a.exitY = ep.ey + const ep = exitPoint(a.x, a.y, w, h); + a.exitX = ep.ex; + a.exitY = ep.ey; // If the ant is heading toward its exit (not detouring), update the target too if (!a.detouring && a.state === 'crossing') { - a.targetX = a.exitX - a.targetY = a.exitY + a.targetX = a.exitX; + a.targetY = a.exitY; } else if (a.detouring) { // Clamp detour point inside new bounds - a.detourX = clamp(a.detourX, 40, w - 40) - a.detourY = clamp(a.detourY, 40, h - 40) - a.targetX = a.detourX - a.targetY = a.detourY + a.detourX = clamp(a.detourX, 40, w - 40); + a.detourY = clamp(a.detourY, 40, h - 40); + a.targetX = a.detourX; + a.targetY = a.detourY; } else if (a.state === 'entering') { - a.targetX = w / 2 - a.targetY = h / 2 + a.targetX = w / 2; + a.targetY = h / 2; } else if (a.state === 'exiting') { - const exitPt = exitPoint(a.x, a.y, w, h) - a.targetX = exitPt.ex - a.targetY = exitPt.ey + const exitPt = exitPoint(a.x, a.y, w, h); + a.targetX = exitPt.ex; + a.targetY = exitPt.ey; } } function onResize() { - resize() + resize(); // Rebuild ground but keep animation state for (const a of ants) { - a.w = w; a.h = h - clampTarget(a) + a.w = w; + a.h = h; + clampTarget(a); } if (eggCarrier) { - eggCarrier.w = w; eggCarrier.h = h - clampTarget(eggCarrier) + eggCarrier.w = w; + eggCarrier.h = h; + clampTarget(eggCarrier); } } function destroy() { - destroyed = true - if (animId) cancelAnimationFrame(animId) - window.removeEventListener('resize', onResize) + destroyed = true; + if (animId) cancelAnimationFrame(animId); + window.removeEventListener('resize', onResize); } // ─── Dev controls ───────────────────────────────────────── - let paused = false - let pausedAt = 0 // performance.now() when paused - let pausedTotal = 0 // accumulated paused ms + let paused = false; + let pausedAt = 0; // performance.now() when paused + let pausedTotal = 0; // accumulated paused ms function pause() { - if (paused || destroyed) return - paused = true - pausedAt = performance.now() - if (animId) cancelAnimationFrame(animId) + if (paused || destroyed) return; + paused = true; + pausedAt = performance.now(); + if (animId) cancelAnimationFrame(animId); } function resume() { - if (!paused || destroyed) return - pausedTotal += performance.now() - pausedAt - paused = false - animId = requestAnimationFrame(loop) + if (!paused || destroyed) return; + pausedTotal += performance.now() - pausedAt; + paused = false; + animId = requestAnimationFrame(loop); } function restart() { - destroyed = false - paused = false - if (animId) cancelAnimationFrame(animId) - startTs = 0 - prevTs = 0 - currentPhase = 'init' - fadeIn = 0 - sceneFadeOut = 0 - emittedCrack = false - eggPlacedAt = 0 - egg = null - eggCarrier = null - particles = new ParticleSystem() - pausedTotal = 0 - pausedAt = 0 - resize() - buildAnts() - animId = requestAnimationFrame(loop) + destroyed = false; + paused = false; + if (animId) cancelAnimationFrame(animId); + startTs = 0; + prevTs = 0; + currentPhase = 'init'; + fadeIn = 0; + sceneFadeOut = 0; + emittedCrack = false; + eggPlacedAt = 0; + egg = null; + eggCarrier = null; + particles = new ParticleSystem(); + pausedTotal = 0; + pausedAt = 0; + resize(); + buildAnts(); + animId = requestAnimationFrame(loop); } function getElapsed() { - if (!startTs) return 0 - const now = paused ? pausedAt : performance.now() - return (now - startTs - pausedTotal) / 1000 + if (!startTs) return 0; + const now = paused ? pausedAt : performance.now(); + return (now - startTs - pausedTotal) / 1000; } function getPhase() { - return currentPhase + return currentPhase; } function isPaused() { - return paused + return paused; } - return { start, destroy, pause, resume, restart, getElapsed, getPhase, isPaused } + return { start, destroy, pause, resume, restart, getElapsed, getPhase, isPaused }; } diff --git a/src/web/src/main.js b/src/web/src/main.js index c87750b..961dd64 100644 --- a/src/web/src/main.js +++ b/src/web/src/main.js @@ -1,24 +1,24 @@ -import './app.css' -import { mount } from 'svelte' -import App from './App.svelte' +import './app.css'; +import { mount } from 'svelte'; +import App from './App.svelte'; -const target = document.getElementById('app') +const target = document.getElementById('app'); if (!target) { - throw new Error('Tiny Claw UI failed to find #app root element.') + throw new Error('Tiny Claw UI failed to find #app root element.'); } -target.textContent = 'Booting Tiny Claw UI...' +target.textContent = 'Booting Tiny Claw UI...'; -let app +let app; try { - target.textContent = '' - app = mount(App, { target }) + target.textContent = ''; + app = mount(App, { target }); } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error.' - target.textContent = `Tiny Claw UI failed to start: ${message}` - console.error(error) + const message = error instanceof Error ? error.message : 'Unknown error.'; + target.textContent = `Tiny Claw UI failed to start: ${message}`; + console.error(error); } -export default app +export default app; diff --git a/src/web/src/preview-hatching.js b/src/web/src/preview-hatching.js index a8a5c3f..2f6755c 100644 --- a/src/web/src/preview-hatching.js +++ b/src/web/src/preview-hatching.js @@ -1,12 +1,12 @@ -import './app.css' -import { mount } from 'svelte' -import HatchingPreview from './HatchingPreview.svelte' +import './app.css'; +import { mount } from 'svelte'; +import HatchingPreview from './HatchingPreview.svelte'; -const target = document.getElementById('app') +const target = document.getElementById('app'); if (!target) { - throw new Error('Preview: failed to find #app root element.') + throw new Error('Preview: failed to find #app root element.'); } -target.textContent = '' -mount(HatchingPreview, { target }) +target.textContent = ''; +mount(HatchingPreview, { target }); diff --git a/src/web/src/security-db.ts b/src/web/src/security-db.ts index 71850e1..9f22307 100644 --- a/src/web/src/security-db.ts +++ b/src/web/src/security-db.ts @@ -7,26 +7,26 @@ * Uses `bun:sqlite` — the same engine used by @tinyclaw/core for agent.db. */ -import { Database as BunDatabase } from 'bun:sqlite' -import { mkdirSync } from 'fs' -import { dirname } from 'path' +import { Database as BunDatabase } from 'bun:sqlite'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface BlockedIPRow { - ip: string - blocked_at: number - reason: string - failed_attempts: number + ip: string; + blocked_at: number; + reason: string; + failed_attempts: number; } export interface RecoveryAttemptRow { - ip: string - failed_attempts: number - locked_until: number - last_attempt_at: number + ip: string; + failed_attempts: number; + locked_until: number; + last_attempt_at: number; } // --------------------------------------------------------------------------- @@ -34,20 +34,20 @@ export interface RecoveryAttemptRow { // --------------------------------------------------------------------------- export class SecurityDatabase { - private db: BunDatabase + private db: BunDatabase; constructor(dbPath: string) { // Ensure directory exists try { - mkdirSync(dirname(dbPath), { recursive: true }) + mkdirSync(dirname(dbPath), { recursive: true }); } catch { // Directory might already exist } - this.db = new BunDatabase(dbPath) + this.db = new BunDatabase(dbPath); // Enable WAL mode for better concurrent-read performance - this.db.exec('PRAGMA journal_mode = WAL') + this.db.exec('PRAGMA journal_mode = WAL'); this.db.exec(` CREATE TABLE IF NOT EXISTS blocked_ips ( @@ -63,7 +63,7 @@ export class SecurityDatabase { locked_until INTEGER NOT NULL DEFAULT 0, last_attempt_at INTEGER NOT NULL DEFAULT 0 ); - `) + `); } // ----------------------------------------------------------------------- @@ -72,10 +72,8 @@ export class SecurityDatabase { /** Check if an IP is permanently blocked. */ isBlocked(ip: string): boolean { - const row = this.db - .query('SELECT 1 FROM blocked_ips WHERE ip = ?') - .get(ip) - return row !== null + const row = this.db.query('SELECT 1 FROM blocked_ips WHERE ip = ?').get(ip); + return row !== null; } /** Permanently block an IP address. */ @@ -83,21 +81,23 @@ export class SecurityDatabase { this.db .query( `INSERT OR REPLACE INTO blocked_ips (ip, blocked_at, reason, failed_attempts) - VALUES (?, ?, ?, ?)` + VALUES (?, ?, ?, ?)`, ) - .run(ip, Date.now(), reason, failedAttempts) + .run(ip, Date.now(), reason, failedAttempts); } /** Unblock an IP address (admin operation). */ unblockIP(ip: string): void { - this.db.query('DELETE FROM blocked_ips WHERE ip = ?').run(ip) + this.db.query('DELETE FROM blocked_ips WHERE ip = ?').run(ip); } /** Get all blocked IPs. */ getBlockedIPs(): BlockedIPRow[] { return this.db - .query('SELECT ip, blocked_at, reason, failed_attempts FROM blocked_ips ORDER BY blocked_at DESC') - .all() as BlockedIPRow[] + .query( + 'SELECT ip, blocked_at, reason, failed_attempts FROM blocked_ips ORDER BY blocked_at DESC', + ) + .all() as BlockedIPRow[]; } // ----------------------------------------------------------------------- @@ -106,47 +106,47 @@ export class SecurityDatabase { /** Get the current recovery attempt record for an IP. */ getRecoveryAttempts(ip: string): RecoveryAttemptRow | null { - return ( - this.db - .query('SELECT ip, failed_attempts, locked_until, last_attempt_at FROM recovery_attempts WHERE ip = ?') - .get(ip) as RecoveryAttemptRow | null - ) + return this.db + .query( + 'SELECT ip, failed_attempts, locked_until, last_attempt_at FROM recovery_attempts WHERE ip = ?', + ) + .get(ip) as RecoveryAttemptRow | null; } /** Record a failed recovery attempt for an IP. Returns the updated row. */ recordFailure(ip: string): RecoveryAttemptRow { - const now = Date.now() + const now = Date.now(); this.db .query( `INSERT INTO recovery_attempts (ip, failed_attempts, locked_until, last_attempt_at) VALUES (?, 1, 0, ?) ON CONFLICT(ip) DO UPDATE SET failed_attempts = failed_attempts + 1, - last_attempt_at = ?` + last_attempt_at = ?`, ) - .run(ip, now, now) + .run(ip, now, now); - return this.getRecoveryAttempts(ip)! + return this.getRecoveryAttempts(ip)!; } /** Set a lockout timestamp for an IP. */ setLockout(ip: string, lockedUntil: number): void { this.db .query('UPDATE recovery_attempts SET locked_until = ? WHERE ip = ?') - .run(lockedUntil, ip) + .run(lockedUntil, ip); } /** Reset recovery attempts on success. */ resetAttempts(ip: string): void { - this.db.query('DELETE FROM recovery_attempts WHERE ip = ?').run(ip) + this.db.query('DELETE FROM recovery_attempts WHERE ip = ?').run(ip); } /** Clean up stale attempt records older than the given age (ms). */ cleanStaleAttempts(maxAgeMs: number): void { - const cutoff = Date.now() - maxAgeMs + const cutoff = Date.now() - maxAgeMs; this.db .query('DELETE FROM recovery_attempts WHERE last_attempt_at < ? AND locked_until < ?') - .run(cutoff, Date.now()) + .run(cutoff, Date.now()); } // ----------------------------------------------------------------------- @@ -156,7 +156,7 @@ export class SecurityDatabase { /** Close the database connection. */ close(): void { try { - this.db.close() + this.db.close(); } catch { // Already closed or never opened } diff --git a/src/web/src/server.ts b/src/web/src/server.ts index e4671ae..50a8bfe 100644 --- a/src/web/src/server.ts +++ b/src/web/src/server.ts @@ -1,46 +1,47 @@ -import { existsSync, chmodSync, statSync } from 'fs' -import { join, resolve } from 'path' -import { timingSafeEqual } from 'crypto' -import { SecurityDatabase } from './security-db' -import { DEFAULT_PROVIDER, DEFAULT_MODEL, DEFAULT_BASE_URL } from '@tinyclaw/core' -import { generateSoulTraits } from '@tinyclaw/heartware' -import { logger } from '@tinyclaw/logger' -import type { ChannelSender, OutboundMessage } from '@tinyclaw/types' +import { timingSafeEqual } from 'node:crypto'; +import { chmodSync, existsSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { DEFAULT_BASE_URL, DEFAULT_MODEL, DEFAULT_PROVIDER } from '@tinyclaw/core'; +import { generateSoulTraits } from '@tinyclaw/heartware'; +import { logger } from '@tinyclaw/logger'; +import type { ChannelSender, OutboundMessage } from '@tinyclaw/types'; +import { SecurityDatabase } from './security-db'; // Inline ANSI helpers for log highlighting (no external dep needed) -const highlight = (text: string) => `\x1b[1m\x1b[36m${text}\x1b[39m\x1b[22m` +const highlight = (text: string) => `\x1b[1m\x1b[36m${text}\x1b[39m\x1b[22m`; + import { - generateRecoveryToken, + verifyTotpCode as _verifyTotpCode, + BACKUP_CODES_COUNT, + createTotpUri, generateBackupCodes, + generateRecoveryToken, + generateSessionToken, generateTotpSecret, - createTotpUri, - verifyTotpCode as _verifyTotpCode, sha256, - generateSessionToken, - BACKUP_CODES_COUNT, -} from '@tinyclaw/core/owner-auth' +} from '@tinyclaw/core/owner-auth'; -const textEncoder = new TextEncoder() +const textEncoder = new TextEncoder(); // --------------------------------------------------------------------------- // Owner Authority — bootstrap setup + session authentication // --------------------------------------------------------------------------- /** Bootstrap/setup token expiry — valid for 1 hour. */ -const TOKEN_EXPIRY_MS = 60 * 60 * 1000 -const SETUP_SESSION_EXPIRY_MS = 15 * 60 * 1000 +const TOKEN_EXPIRY_MS = 60 * 60 * 1000; +const SETUP_SESSION_EXPIRY_MS = 15 * 60 * 1000; /** * Human-friendly alphabet for secrets/codes — excludes ambiguous characters * (0/O, 1/I/L) following the OpenClaw pairing-code pattern. */ -const TOKEN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' +const TOKEN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; -const BOOTSTRAP_SECRET_LENGTH = 30 +const BOOTSTRAP_SECRET_LENGTH = 30; interface SetupSession { - expiresAt: number - totpSecret: string + expiresAt: number; + totpSecret: string; } /** @@ -48,24 +49,24 @@ interface SetupSession { * Uses 30 characters from a 32-char human-friendly alphabet (~150-bit entropy). */ function generateClaimToken(): string { - const bytes = crypto.getRandomValues(new Uint8Array(BOOTSTRAP_SECRET_LENGTH)) - const chars = Array.from(bytes, b => TOKEN_ALPHABET[b % TOKEN_ALPHABET.length]) - return chars.join('') + const bytes = crypto.getRandomValues(new Uint8Array(BOOTSTRAP_SECRET_LENGTH)); + const chars = Array.from(bytes, (b) => TOKEN_ALPHABET[b % TOKEN_ALPHABET.length]); + return chars.join(''); } function buildProviderApiKeyName(providerName: string): string { - return `provider.${providerName}.apiKey` + return `provider.${providerName}.apiKey`; } function parseSoulSeed(value?: string): number { - const raw = String(value ?? '').trim() + const raw = String(value ?? '').trim(); if (!raw) { - const random = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER) - return random + const random = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); + return random; } - const parsed = Number.parseInt(raw, 10) - if (!Number.isFinite(parsed)) throw new Error('Soul seed must be a valid integer') - return parsed + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed)) throw new Error('Soul seed must be a valid integer'); + return parsed; } /** @@ -74,21 +75,21 @@ function parseSoulSeed(value?: string): number { * (Pattern from OpenClaw's src/security/secret-equal.ts) */ function timingSafeCompare(a: string, b: string): boolean { - if (typeof a !== 'string' || typeof b !== 'string') return false - const enc = new TextEncoder() - const bufA = enc.encode(a) - const bufB = enc.encode(b) + if (typeof a !== 'string' || typeof b !== 'string') return false; + const enc = new TextEncoder(); + const bufA = enc.encode(a); + const bufB = enc.encode(b); // If lengths differ, compare bufA against itself (constant time) and return false if (bufA.byteLength !== bufB.byteLength) { - timingSafeEqual(bufA, bufA) // burn the same CPU time - return false + timingSafeEqual(bufA, bufA); // burn the same CPU time + return false; } - return timingSafeEqual(bufA, bufB) + return timingSafeEqual(bufA, bufB); } /** TOTP verification using timing-safe comparison. */ async function verifyTotpCode(secret: string, code: string): Promise { - return _verifyTotpCode(secret, code, timingSafeCompare) + return _verifyTotpCode(secret, code, timingSafeCompare); } // --------------------------------------------------------------------------- @@ -97,35 +98,35 @@ async function verifyTotpCode(secret: string, code: string): Promise { interface RateLimitEntry { /** Timestamps of recent attempts within the window. */ - attempts: number[] + attempts: number[]; /** If set, requests are blocked until this timestamp. */ - lockedUntil: number + lockedUntil: number; } -const RATE_LIMIT_WINDOW_MS = 60_000 // 60-second sliding window -const RATE_LIMIT_MAX_ATTEMPTS = 5 // max 5 attempts per window -const RATE_LIMIT_LOCKOUT_MS = 5 * 60_000 // 5-minute lockout after exceeding -const rateLimitStore = new Map() +const RATE_LIMIT_WINDOW_MS = 60_000; // 60-second sliding window +const RATE_LIMIT_MAX_ATTEMPTS = 5; // max 5 attempts per window +const RATE_LIMIT_LOCKOUT_MS = 5 * 60_000; // 5-minute lockout after exceeding +const rateLimitStore = new Map(); // --------------------------------------------------------------------------- // Recovery Rate Limiting — persistent, stricter: 3 attempts, exponential backoff // Permanent IP block after MAX_TOTAL_RECOVERY_FAILURES total failures // --------------------------------------------------------------------------- -const RECOVERY_MAX_ATTEMPTS = 3 -const RECOVERY_BASE_LOCKOUT_MS = 60_000 // 1 minute base -const MAX_TOTAL_RECOVERY_FAILURES = 10 // permanently block after this many total failures +const RECOVERY_MAX_ATTEMPTS = 3; +const RECOVERY_BASE_LOCKOUT_MS = 60_000; // 1 minute base +const MAX_TOTAL_RECOVERY_FAILURES = 10; // permanently block after this many total failures /** * Security database instance — initialized inside createWebUI when dataDir is available. * Null when no dataDir is provided (e.g. in tests without persistence). */ -let securityDb: SecurityDatabase | null = null +let securityDb: SecurityDatabase | null = null; /** * In-memory fallback for environments without a security database (tests, etc.). */ -const recoveryRateLimitStore = new Map() +const recoveryRateLimitStore = new Map(); /** * Check rate limit for a given key (typically IP address). @@ -133,30 +134,30 @@ const recoveryRateLimitStore = new Map now) return false + if (entry.lockedUntil > now) return false; // Clean old attempts outside the window - entry.attempts = entry.attempts.filter(t => now - t < RATE_LIMIT_WINDOW_MS) + entry.attempts = entry.attempts.filter((t) => now - t < RATE_LIMIT_WINDOW_MS); // Check if under limit if (entry.attempts.length >= RATE_LIMIT_MAX_ATTEMPTS) { - entry.lockedUntil = now + RATE_LIMIT_LOCKOUT_MS - return false + entry.lockedUntil = now + RATE_LIMIT_LOCKOUT_MS; + return false; } - entry.attempts.push(now) - return true + entry.attempts.push(now); + return true; } /** @@ -165,48 +166,52 @@ function checkRateLimit(key: string): boolean { * Permanently blocked IPs are always denied. * Returns { allowed: true } or { allowed: false, retryAfterMs, permanent }. */ -function checkRecoveryRateLimit(key: string): { allowed: boolean; retryAfterMs?: number; permanent?: boolean } { +function checkRecoveryRateLimit(key: string): { + allowed: boolean; + retryAfterMs?: number; + permanent?: boolean; +} { // Check permanent block first (persistent DB) if (securityDb?.isBlocked(key)) { - return { allowed: false, permanent: true } + return { allowed: false, permanent: true }; } - const now = Date.now() + const now = Date.now(); if (securityDb) { - const row = securityDb.getRecoveryAttempts(key) - if (!row) return { allowed: true } + const row = securityDb.getRecoveryAttempts(key); + if (!row) return { allowed: true }; if (row.locked_until > now) { - return { allowed: false, retryAfterMs: row.locked_until - now } + return { allowed: false, retryAfterMs: row.locked_until - now }; } if (row.failed_attempts >= RECOVERY_MAX_ATTEMPTS) { - const lockoutMultiplier = Math.pow(2, Math.floor(row.failed_attempts / RECOVERY_MAX_ATTEMPTS) - 1) - const lockoutMs = RECOVERY_BASE_LOCKOUT_MS * lockoutMultiplier - securityDb.setLockout(key, now + lockoutMs) - return { allowed: false, retryAfterMs: lockoutMs } + const lockoutMultiplier = 2 ** (Math.floor(row.failed_attempts / RECOVERY_MAX_ATTEMPTS) - 1); + const lockoutMs = RECOVERY_BASE_LOCKOUT_MS * lockoutMultiplier; + securityDb.setLockout(key, now + lockoutMs); + return { allowed: false, retryAfterMs: lockoutMs }; } - return { allowed: true } + return { allowed: true }; } // Fallback: in-memory - let entry = recoveryRateLimitStore.get(key) - if (!entry) return { allowed: true } + const entry = recoveryRateLimitStore.get(key); + if (!entry) return { allowed: true }; if (entry.lockedUntil > now) { - return { allowed: false, retryAfterMs: entry.lockedUntil - now } + return { allowed: false, retryAfterMs: entry.lockedUntil - now }; } if (entry.failedAttempts >= RECOVERY_MAX_ATTEMPTS) { - const lockoutMultiplier = Math.pow(2, Math.floor(entry.failedAttempts / RECOVERY_MAX_ATTEMPTS) - 1) - const lockoutMs = RECOVERY_BASE_LOCKOUT_MS * lockoutMultiplier - entry.lockedUntil = now + lockoutMs - return { allowed: false, retryAfterMs: lockoutMs } + const lockoutMultiplier = 2 ** (Math.floor(entry.failedAttempts / RECOVERY_MAX_ATTEMPTS) - 1); + const lockoutMs = RECOVERY_BASE_LOCKOUT_MS * lockoutMultiplier; + entry.lockedUntil = now + lockoutMs; + return { allowed: false, retryAfterMs: lockoutMs }; } - return { allowed: true } + return { allowed: true }; } /** @@ -215,34 +220,34 @@ function checkRecoveryRateLimit(key: string): { allowed: boolean; retryAfterMs?: */ function recordRecoveryFailure(key: string): void { if (securityDb) { - const row = securityDb.recordFailure(key) + const row = securityDb.recordFailure(key); // Permanent block after reaching the threshold if (row.failed_attempts >= MAX_TOTAL_RECOVERY_FAILURES) { - securityDb.blockIP(key, 'max_recovery_attempts', row.failed_attempts) - securityDb.resetAttempts(key) - return + securityDb.blockIP(key, 'max_recovery_attempts', row.failed_attempts); + securityDb.resetAttempts(key); + return; } // Start lockout if they hit the attempt limit if (row.failed_attempts >= RECOVERY_MAX_ATTEMPTS) { - const lockoutMultiplier = Math.pow(2, Math.floor(row.failed_attempts / RECOVERY_MAX_ATTEMPTS) - 1) - securityDb.setLockout(key, Date.now() + (RECOVERY_BASE_LOCKOUT_MS * lockoutMultiplier)) + const lockoutMultiplier = 2 ** (Math.floor(row.failed_attempts / RECOVERY_MAX_ATTEMPTS) - 1); + securityDb.setLockout(key, Date.now() + RECOVERY_BASE_LOCKOUT_MS * lockoutMultiplier); } - return + return; } // Fallback: in-memory - let entry = recoveryRateLimitStore.get(key) + let entry = recoveryRateLimitStore.get(key); if (!entry) { - entry = { failedAttempts: 0, lockedUntil: 0 } - recoveryRateLimitStore.set(key, entry) + entry = { failedAttempts: 0, lockedUntil: 0 }; + recoveryRateLimitStore.set(key, entry); } - entry.failedAttempts++ + entry.failedAttempts++; if (entry.failedAttempts >= RECOVERY_MAX_ATTEMPTS) { - const lockoutMultiplier = Math.pow(2, Math.floor(entry.failedAttempts / RECOVERY_MAX_ATTEMPTS) - 1) - entry.lockedUntil = Date.now() + (RECOVERY_BASE_LOCKOUT_MS * lockoutMultiplier) + const lockoutMultiplier = 2 ** (Math.floor(entry.failedAttempts / RECOVERY_MAX_ATTEMPTS) - 1); + entry.lockedUntil = Date.now() + RECOVERY_BASE_LOCKOUT_MS * lockoutMultiplier; } } @@ -251,10 +256,10 @@ function recordRecoveryFailure(key: string): void { */ function resetRecoveryRateLimit(key: string): void { if (securityDb) { - securityDb.resetAttempts(key) - return + securityDb.resetAttempts(key); + return; } - recoveryRateLimitStore.delete(key) + recoveryRateLimitStore.delete(key); } /** @@ -262,35 +267,35 @@ function resetRecoveryRateLimit(key: string): void { */ function getClientIP(request: Request, server: any): string { // Check standard proxy headers first - const forwarded = request.headers.get('x-forwarded-for') - if (forwarded) return forwarded.split(',')[0].trim() - const realIP = request.headers.get('x-real-ip') - if (realIP) return realIP + const forwarded = request.headers.get('x-forwarded-for'); + if (forwarded) return forwarded.split(',')[0].trim(); + const realIP = request.headers.get('x-real-ip'); + if (realIP) return realIP; // Fall back to Bun's socket address try { - const addr = server?.requestIP?.(request) - if (addr) return addr.address + const addr = server?.requestIP?.(request); + if (addr) return addr.address; } catch {} - return 'unknown' + return 'unknown'; } // Periodically clean stale rate-limit entries (every 10 minutes) setInterval(() => { - const now = Date.now() + const now = Date.now(); for (const [key, entry] of rateLimitStore) { - if (entry.lockedUntil < now && entry.attempts.every(t => now - t > RATE_LIMIT_WINDOW_MS)) { - rateLimitStore.delete(key) + if (entry.lockedUntil < now && entry.attempts.every((t) => now - t > RATE_LIMIT_WINDOW_MS)) { + rateLimitStore.delete(key); } } // Clean in-memory fallback store for (const [key, entry] of recoveryRateLimitStore) { if (entry.lockedUntil < now - 30 * 60_000) { - recoveryRateLimitStore.delete(key) + recoveryRateLimitStore.delete(key); } } // Clean persistent DB stale attempts (30 min inactive) - securityDb?.cleanStaleAttempts(30 * 60_000) -}, 10 * 60_000).unref?.() + securityDb?.cleanStaleAttempts(30 * 60_000); +}, 10 * 60_000).unref?.(); // --------------------------------------------------------------------------- // File Permission Hardening (non-Windows) @@ -302,11 +307,11 @@ setInterval(() => { * Skipped on Windows where chmod is not meaningful. */ function hardenFilePermissions(filePath: string): void { - if (process.platform === 'win32') return + if (process.platform === 'win32') return; try { - const stats = statSync(filePath) - const targetMode = stats.isDirectory() ? 0o700 : 0o600 - chmodSync(filePath, targetMode) + const stats = statSync(filePath); + const targetMode = stats.isDirectory() ? 0o700 : 0o600; + chmodSync(filePath, targetMode); } catch { // Silently ignore — file may not exist yet } @@ -316,10 +321,10 @@ function hardenFilePermissions(filePath: string): void { * Extract the session token from a request's Cookie header. */ function getSessionToken(request: Request): string | null { - const cookie = request.headers.get('cookie') - if (!cookie) return null - const match = cookie.match(/(?:^|;\s*)tinyclaw_session=([^;]+)/) - return match ? match[1] : null + const cookie = request.headers.get('cookie'); + if (!cookie) return null; + const match = cookie.match(/(?:^|;\s*)tinyclaw_session=([^;]+)/); + return match ? match[1] : null; } /** @@ -327,8 +332,8 @@ function getSessionToken(request: Request): string | null { * HttpOnly, SameSite=Strict, persistent (1 year), path=/. */ function buildSessionCookie(token: string): string { - const maxAge = 365 * 24 * 60 * 60 // 1 year - return `tinyclaw_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}` + const maxAge = 365 * 24 * 60 * 60; // 1 year + return `tinyclaw_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`; } /** @@ -340,7 +345,7 @@ const SECURITY_HEADERS: Record = { 'X-Frame-Options': 'DENY', 'X-XSS-Protection': '1; mode=block', 'Referrer-Policy': 'strict-origin-when-cross-origin', -} +}; function jsonResponse(payload, status = 200) { return new Response(JSON.stringify(payload), { @@ -348,8 +353,8 @@ function jsonResponse(payload, status = 200) { headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS, - } - }) + }, + }); } function htmlResponse(html, status = 200) { @@ -358,38 +363,35 @@ function htmlResponse(html, status = 200) { headers: { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY_HEADERS, - } - }) + }, + }); } function fileResponse(filePath) { return new Response(Bun.file(filePath), { headers: SECURITY_HEADERS, - }) + }); } function resolveUiPaths(overrideWebRoot?: string) { - const webRoot = overrideWebRoot || resolve(import.meta.dir, '..') + const webRoot = overrideWebRoot || resolve(import.meta.dir, '..'); return { webRoot, distDir: join(webRoot, 'dist'), - publicDir: join(webRoot, 'public') - } + publicDir: join(webRoot, 'public'), + }; } function findStaticFile(pathname, overrideWebRoot?: string) { - const { distDir, publicDir } = resolveUiPaths(overrideWebRoot) + const { distDir, publicDir } = resolveUiPaths(overrideWebRoot); - const candidates = [ - join(distDir, pathname), - join(publicDir, pathname) - ] + const candidates = [join(distDir, pathname), join(publicDir, pathname)]; for (const candidate of candidates) { - if (existsSync(candidate)) return candidate + if (existsSync(candidate)) return candidate; } - return null + return null; } function buildDevNotice() { @@ -427,7 +429,7 @@ function buildDevNotice() { -` +`; } export function createWebUI(config) { @@ -444,59 +446,59 @@ export function createWebUI(config) { configDbPath, dataDir, webRoot: configWebRoot, - } = config + } = config; - const serverStartedAt = Date.now() - let server = null + const serverStartedAt = Date.now(); + let server = null; // --------------------------------------------------------------------------- // SSE push — outbound gateway delivery channel for the web UI // --------------------------------------------------------------------------- - type SSEClient = ReadableStreamDefaultController - const sseClients = new Set() - const sseEncoder = new TextEncoder() + type SSEClient = ReadableStreamDefaultController; + const sseClients = new Set(); + const sseEncoder = new TextEncoder(); /** Push an SSE event to all connected web clients. */ function pushToAllClients(event: string, data: unknown): void { - const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` - const encoded = sseEncoder.encode(payload) + const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + const encoded = sseEncoder.encode(payload); for (const client of sseClients) { try { - client.enqueue(encoded) + client.enqueue(encoded); } catch { // Client disconnected — remove on next heartbeat sweep - sseClients.delete(client) + sseClients.delete(client); } } } // Initialize persistent security database when dataDir is available if (dataDir) { - const securityDbPath = join(dataDir, 'data', 'security.db') - securityDb = new SecurityDatabase(securityDbPath) - hardenFilePermissions(securityDbPath) + const securityDbPath = join(dataDir, 'data', 'security.db'); + securityDb = new SecurityDatabase(securityDbPath); + hardenFilePermissions(securityDbPath); } // Bootstrap secret — generated once per boot for first-time setup claim. - let claimToken: string | null = null - let claimTokenCreatedAt: number = 0 - const setupSessions = new Map() + let claimToken: string | null = null; + let claimTokenCreatedAt: number = 0; + const setupSessions = new Map(); // Recovery sessions — validated recovery tokens (token hash → expiry) - const recoveryValidSessions = new Map() - const RECOVERY_SESSION_EXPIRY_MS = 10 * 60_000 // 10 minutes + const recoveryValidSessions = new Map(); + const RECOVERY_SESSION_EXPIRY_MS = 10 * 60_000; // 10 minutes // Harden config database permissions on startup if (configDbPath) { - hardenFilePermissions(configDbPath) + hardenFilePermissions(configDbPath); } /** * Check if ownership has been claimed. */ function isOwnerClaimed(): boolean { - if (!configManager) return false - return Boolean(configManager.get('owner.ownerId')) + if (!configManager) return false; + return Boolean(configManager.get('owner.ownerId')); } /** @@ -504,13 +506,13 @@ export function createWebUI(config) { * Uses timing-safe comparison on the hash to prevent timing attacks. */ async function isOwnerRequest(request: Request): Promise { - if (!configManager) return false - const storedHash = configManager.get('owner.sessionTokenHash') - if (!storedHash) return false - const token = getSessionToken(request) - if (!token) return false - const hash = await sha256(token) - return timingSafeCompare(hash, storedHash) + if (!configManager) return false; + const storedHash = configManager.get('owner.sessionTokenHash'); + if (!storedHash) return false; + const token = getSessionToken(request); + if (!token) return false; + const hash = await sha256(token); + return timingSafeCompare(hash, storedHash); } /** @@ -518,57 +520,63 @@ export function createWebUI(config) { * Tokens expire after TOKEN_EXPIRY_MS (1 hour). */ function getOrCreateClaimToken(): string { - const now = Date.now() - if (!claimToken || (now - claimTokenCreatedAt) > TOKEN_EXPIRY_MS) { - claimToken = generateClaimToken() - claimTokenCreatedAt = now + const now = Date.now(); + if (!claimToken || now - claimTokenCreatedAt > TOKEN_EXPIRY_MS) { + claimToken = generateClaimToken(); + claimTokenCreatedAt = now; } - return claimToken + return claimToken; } /** * Check if the claim token is still valid (not expired). */ function isClaimTokenValid(): boolean { - if (!claimToken) return false - return (Date.now() - claimTokenCreatedAt) <= TOKEN_EXPIRY_MS + if (!claimToken) return false; + return Date.now() - claimTokenCreatedAt <= TOKEN_EXPIRY_MS; } function getOrCreateSetupSession(): { token: string; session: SetupSession } { - const token = generateSessionToken() + const token = generateSessionToken(); const session: SetupSession = { expiresAt: Date.now() + SETUP_SESSION_EXPIRY_MS, totpSecret: generateTotpSecret(), - } - setupSessions.set(token, session) - return { token, session } + }; + setupSessions.set(token, session); + return { token, session }; } function getSetupSession(token?: string): SetupSession | null { - if (!token) return null - const existing = setupSessions.get(token) - if (!existing) return null + if (!token) return null; + const existing = setupSessions.get(token); + if (!existing) return null; if (existing.expiresAt < Date.now()) { - setupSessions.delete(token) - return null + setupSessions.delete(token); + return null; } - return existing + return existing; } return { async start() { - if (server) return + if (server) return; if (!isOwnerClaimed()) { // First-time: display bootstrap secret - const token = getOrCreateClaimToken() - logger.info('─'.repeat(52), 'web', { emoji: '' }) - logger.info(`Bootstrap secret: ${highlight(token)}`, 'web', { emoji: '🔑' }) - logger.info(`Open ${highlight('/setup')} and enter this to claim ownership (expires in 1 hour)`, 'web', { emoji: '🔗' }) - logger.info('─'.repeat(52), 'web', { emoji: '' }) + const token = getOrCreateClaimToken(); + logger.info('─'.repeat(52), 'web', { emoji: '' }); + logger.info(`Bootstrap secret: ${highlight(token)}`, 'web', { emoji: '🔑' }); + logger.info( + `Open ${highlight('/setup')} and enter this to claim ownership (expires in 1 hour)`, + 'web', + { emoji: '🔗' }, + ); + logger.info('─'.repeat(52), 'web', { emoji: '' }); } else { // Already claimed: owner can log in via /login - logger.info(`Owner claimed — open ${highlight('/login')} to access the dashboard`, 'web', { emoji: '🔗' }) + logger.info(`Owner claimed — open ${highlight('/login')} to access the dashboard`, 'web', { + emoji: '🔗', + }); } server = Bun.serve({ @@ -582,12 +590,12 @@ export function createWebUI(config) { // may be terminated by the runtime. idleTimeout: 255, fetch: async (request) => { - const url = new URL(request.url) - const pathname = url.pathname + const url = new URL(request.url); + const pathname = url.pathname; - const now = Date.now() + const now = Date.now(); for (const [token, session] of setupSessions.entries()) { - if (session.expiresAt < now) setupSessions.delete(token) + if (session.expiresAt < now) setupSessions.delete(token); } // ================================================================= @@ -595,47 +603,55 @@ export function createWebUI(config) { // ================================================================= if (pathname === '/api/health' && request.method === 'GET') { - return jsonResponse({ ok: true, startedAt: serverStartedAt }) + return jsonResponse({ ok: true, startedAt: serverStartedAt }); } // Auth status — tells the UI whether owner is claimed and whether // the current request is from the owner. if (pathname === '/api/auth/status' && request.method === 'GET') { - const claimed = isOwnerClaimed() - const isOwner = claimed ? await isOwnerRequest(request) : false + const claimed = isOwnerClaimed(); + const isOwner = claimed ? await isOwnerRequest(request) : false; return jsonResponse({ claimed, isOwner, setupRequired: !claimed, mfaConfigured: Boolean(await secretsManager?.retrieve('owner.totpSecret')), - }) + }); } // Bootstrap verification — first step of /setup flow if (pathname === '/api/setup/bootstrap' && request.method === 'POST') { // Rate limit login attempts - const clientIP = getClientIP(request, server) + const clientIP = getClientIP(request, server); if (!checkRateLimit(clientIP)) { - return jsonResponse({ error: 'Too many attempts. Try again later.' }, 429) + return jsonResponse({ error: 'Too many attempts. Try again later.' }, 429); } if (isOwnerClaimed()) { - return jsonResponse({ error: 'Setup already completed.' }, 403) + return jsonResponse({ error: 'Setup already completed.' }, 403); } - let body + let body: unknown; try { - body = await request.json() + body = await request.json(); } catch { - return jsonResponse({ error: 'Invalid JSON' }, 400) + return jsonResponse({ error: 'Invalid JSON' }, 400); } - const secret = String(body?.secret ?? '').trim().toUpperCase() + const secret = String(body?.secret ?? '') + .trim() + .toUpperCase(); if (!secret || !isClaimTokenValid() || !timingSafeCompare(secret, claimToken!)) { - return jsonResponse({ error: 'Invalid or expired bootstrap secret. Restart Tiny Claw to generate a new one.' }, 401) + return jsonResponse( + { + error: + 'Invalid or expired bootstrap secret. Restart Tiny Claw to generate a new one.', + }, + 401, + ); } - const { token: setupToken, session } = getOrCreateSetupSession() + const { token: setupToken, session } = getOrCreateSetupSession(); return jsonResponse({ ok: true, @@ -646,93 +662,102 @@ export function createWebUI(config) { defaultBaseUrl: DEFAULT_BASE_URL, totpSecret: session.totpSecret, totpUri: createTotpUri(session.totpSecret), - }) + }); } // Setup completion — persist owner, API key, soul seed, TOTP config if (pathname === '/api/setup/complete' && request.method === 'POST') { - const clientIP = getClientIP(request, server) + const clientIP = getClientIP(request, server); if (!checkRateLimit(clientIP)) { - return jsonResponse({ error: 'Too many attempts. Try again later.' }, 429) + return jsonResponse({ error: 'Too many attempts. Try again later.' }, 429); } if (isOwnerClaimed()) { - return jsonResponse({ error: 'Setup already completed.' }, 403) + return jsonResponse({ error: 'Setup already completed.' }, 403); } - let body + let body: unknown; try { - body = await request.json() + body = await request.json(); } catch { - return jsonResponse({ error: 'Invalid JSON' }, 400) + return jsonResponse({ error: 'Invalid JSON' }, 400); } - const setupToken = String(body?.setupToken ?? '') - const session = getSetupSession(setupToken) + const setupToken = String(body?.setupToken ?? ''); + const session = getSetupSession(setupToken); if (!session) { - return jsonResponse({ error: 'Setup session expired. Re-enter the bootstrap secret.' }, 401) + return jsonResponse( + { error: 'Setup session expired. Re-enter the bootstrap secret.' }, + 401, + ); } - const acceptRisk = Boolean(body?.acceptRisk) + const acceptRisk = Boolean(body?.acceptRisk); if (!acceptRisk) { - return jsonResponse({ error: 'You must accept the security warning to continue.' }, 400) + return jsonResponse( + { error: 'You must accept the security warning to continue.' }, + 400, + ); } - const apiKey = String(body?.apiKey ?? '').trim() + const apiKey = String(body?.apiKey ?? '').trim(); if (!apiKey) { - return jsonResponse({ error: 'API key is required.' }, 400) + return jsonResponse({ error: 'API key is required.' }, 400); } - const totpCode = String(body?.totpCode ?? '').trim() - const isValidTotp = await verifyTotpCode(session.totpSecret, totpCode) + const totpCode = String(body?.totpCode ?? '').trim(); + const isValidTotp = await verifyTotpCode(session.totpSecret, totpCode); if (!isValidTotp) { - return jsonResponse({ error: 'Invalid TOTP code. Check your authenticator and try again.' }, 400) + return jsonResponse( + { error: 'Invalid TOTP code. Check your authenticator and try again.' }, + 400, + ); } - let soulSeed: number + let soulSeed: number; try { - soulSeed = parseSoulSeed(body?.soulSeed) + soulSeed = parseSoulSeed(body?.soulSeed); } catch (error) { - return jsonResponse({ error: (error as Error).message }, 400) + return jsonResponse({ error: (error as Error).message }, 400); } - const backupCodes = generateBackupCodes(BACKUP_CODES_COUNT) - const backupCodeHashes = await Promise.all(backupCodes.map((code) => sha256(code))) + const backupCodes = generateBackupCodes(BACKUP_CODES_COUNT); + const backupCodeHashes = await Promise.all(backupCodes.map((code) => sha256(code))); - const recoveryToken = generateRecoveryToken() - const recoveryTokenHash = await sha256(recoveryToken) + const recoveryToken = generateRecoveryToken(); + const recoveryTokenHash = await sha256(recoveryToken); - const sessionToken = generateSessionToken() - const sessionHash = await sha256(sessionToken) - const ownerId = 'web:owner' + const sessionToken = generateSessionToken(); + const sessionHash = await sha256(sessionToken); + const ownerId = 'web:owner'; if (!configManager || !secretsManager) { - return jsonResponse({ error: 'Server setup managers are not available.' }, 500) + return jsonResponse({ error: 'Server setup managers are not available.' }, 500); } - await secretsManager.store(buildProviderApiKeyName(DEFAULT_PROVIDER), apiKey) + await secretsManager.store(buildProviderApiKeyName(DEFAULT_PROVIDER), apiKey); configManager.set('providers.starterBrain', { model: DEFAULT_MODEL, baseUrl: DEFAULT_BASE_URL, apiKeyRef: buildProviderApiKeyName(DEFAULT_PROVIDER), - }) - configManager.set('heartware.seed', soulSeed) - configManager.set('owner.ownerId', ownerId) - configManager.set('owner.sessionTokenHash', sessionHash) - configManager.set('owner.claimedAt', Date.now()) - await secretsManager.store('owner.totpSecret', session.totpSecret) - configManager.set('owner.backupCodeHashes', backupCodeHashes) - configManager.set('owner.backupCodesRemaining', backupCodeHashes.length) - configManager.set('owner.recoveryTokenHash', recoveryTokenHash) - configManager.set('owner.mfaConfiguredAt', Date.now()) + }); + configManager.set('heartware.seed', soulSeed); + configManager.set('owner.ownerId', ownerId); + configManager.set('owner.sessionTokenHash', sessionHash); + configManager.set('owner.claimedAt', Date.now()); + await secretsManager.store('owner.totpSecret', session.totpSecret); + configManager.set('owner.backupCodeHashes', backupCodeHashes); + configManager.set('owner.backupCodesRemaining', backupCodeHashes.length); + configManager.set('owner.recoveryTokenHash', recoveryTokenHash); + configManager.set('owner.mfaConfiguredAt', Date.now()); // Clear one-time setup state after successful claim - claimToken = null - setupSessions.delete(setupToken) + claimToken = null; + setupSessions.delete(setupToken); if (onOwnerClaimed) { - onOwnerClaimed(ownerId) + onOwnerClaimed(ownerId); } return new Response(JSON.stringify({ ok: true, backupCodes, recoveryToken }), { @@ -741,46 +766,49 @@ export function createWebUI(config) { 'Content-Type': 'application/json', 'Set-Cookie': buildSessionCookie(sessionToken), }, - }) + }); } // Owner login — re-authenticate using TOTP if (pathname === '/api/auth/login' && request.method === 'POST') { - const clientIP = getClientIP(request, server) + const clientIP = getClientIP(request, server); if (!checkRateLimit(clientIP)) { - return jsonResponse({ error: 'Too many attempts. Try again later.' }, 429) + return jsonResponse({ error: 'Too many attempts. Try again later.' }, 429); } if (!isOwnerClaimed()) { - return jsonResponse({ error: 'No owner is configured yet. Complete /setup first.' }, 400) + return jsonResponse( + { error: 'No owner is configured yet. Complete /setup first.' }, + 400, + ); } - let body + let body: unknown; try { - body = await request.json() + body = await request.json(); } catch { - return jsonResponse({ error: 'Invalid JSON' }, 400) + return jsonResponse({ error: 'Invalid JSON' }, 400); } - const totpSecret = await secretsManager?.retrieve('owner.totpSecret') + const totpSecret = await secretsManager?.retrieve('owner.totpSecret'); if (!totpSecret) { - return jsonResponse({ error: 'Owner MFA is not configured.' }, 400) + return jsonResponse({ error: 'Owner MFA is not configured.' }, 400); } - const totpCode = String(body?.totpCode ?? '').trim() + const totpCode = String(body?.totpCode ?? '').trim(); if (!totpCode) { - return jsonResponse({ error: 'Enter your authenticator code.' }, 400) + return jsonResponse({ error: 'Enter your authenticator code.' }, 400); } - const authenticated = await verifyTotpCode(totpSecret, totpCode) + const authenticated = await verifyTotpCode(totpSecret, totpCode); if (!authenticated) { - return jsonResponse({ error: 'Invalid code.' }, 401) + return jsonResponse({ error: 'Invalid code.' }, 401); } - const sessionToken = generateSessionToken() - const hash = await sha256(sessionToken) - configManager?.set('owner.sessionTokenHash', hash) + const sessionToken = generateSessionToken(); + const hash = await sha256(sessionToken); + configManager?.set('owner.sessionTokenHash', hash); return new Response(JSON.stringify({ ok: true }), { status: 200, @@ -788,7 +816,7 @@ export function createWebUI(config) { 'Content-Type': 'application/json', 'Set-Cookie': buildSessionCookie(sessionToken), }, - }) + }); } // ================================================================= @@ -797,129 +825,147 @@ export function createWebUI(config) { // Validate recovery token — grants a short-lived recovery session if (pathname === '/api/recovery/validate-token' && request.method === 'POST') { - const clientIP = getClientIP(request, server) - const rateCheck = checkRecoveryRateLimit(clientIP) + const clientIP = getClientIP(request, server); + const rateCheck = checkRecoveryRateLimit(clientIP); if (!rateCheck.allowed) { if (rateCheck.permanent) { - return jsonResponse({ error: 'Access permanently blocked.' }, 403) + return jsonResponse({ error: 'Access permanently blocked.' }, 403); } - const retrySeconds = Math.ceil((rateCheck.retryAfterMs || 60_000) / 1000) - return jsonResponse({ error: `Too many attempts. Try again in ${retrySeconds} seconds.` }, 429) + const retrySeconds = Math.ceil((rateCheck.retryAfterMs || 60_000) / 1000); + return jsonResponse( + { error: `Too many attempts. Try again in ${retrySeconds} seconds.` }, + 429, + ); } if (!isOwnerClaimed()) { - return jsonResponse({ error: 'No owner is configured.' }, 400) + return jsonResponse({ error: 'No owner is configured.' }, 400); } - let body + let body: unknown; try { - body = await request.json() + body = await request.json(); } catch { - return jsonResponse({ error: 'Invalid request.' }, 400) + return jsonResponse({ error: 'Invalid request.' }, 400); } - const token = String(body?.token ?? '').trim().toUpperCase() + const token = String(body?.token ?? '') + .trim() + .toUpperCase(); if (!token) { - recordRecoveryFailure(clientIP) - return jsonResponse({ error: 'Invalid token.' }, 401) + recordRecoveryFailure(clientIP); + return jsonResponse({ error: 'Invalid token.' }, 401); } - const storedHash = configManager?.get('owner.recoveryTokenHash') + const storedHash = configManager?.get('owner.recoveryTokenHash'); if (!storedHash) { - recordRecoveryFailure(clientIP) - return jsonResponse({ error: 'Invalid token.' }, 401) + recordRecoveryFailure(clientIP); + return jsonResponse({ error: 'Invalid token.' }, 401); } - const submittedHash = await sha256(token) + const submittedHash = await sha256(token); if (!timingSafeCompare(submittedHash, storedHash)) { - recordRecoveryFailure(clientIP) - return jsonResponse({ error: 'Invalid token.' }, 401) + recordRecoveryFailure(clientIP); + return jsonResponse({ error: 'Invalid token.' }, 401); } // Token valid — create a recovery session - resetRecoveryRateLimit(clientIP) - const recoverySessionId = generateSessionToken() - recoveryValidSessions.set(recoverySessionId, Date.now() + RECOVERY_SESSION_EXPIRY_MS) + resetRecoveryRateLimit(clientIP); + const recoverySessionId = generateSessionToken(); + recoveryValidSessions.set(recoverySessionId, Date.now() + RECOVERY_SESSION_EXPIRY_MS); return jsonResponse({ ok: true, recoverySessionId, expiresInMs: RECOVERY_SESSION_EXPIRY_MS, - }) + }); } // Use backup code to regain access — requires valid recovery session if (pathname === '/api/recovery/use-backup' && request.method === 'POST') { - const clientIP = getClientIP(request, server) - const rateCheck = checkRecoveryRateLimit(clientIP) + const clientIP = getClientIP(request, server); + const rateCheck = checkRecoveryRateLimit(clientIP); if (!rateCheck.allowed) { if (rateCheck.permanent) { - return jsonResponse({ error: 'Access permanently blocked.' }, 403) + return jsonResponse({ error: 'Access permanently blocked.' }, 403); } - const retrySeconds = Math.ceil((rateCheck.retryAfterMs || 60_000) / 1000) - return jsonResponse({ error: `Too many attempts. Try again in ${retrySeconds} seconds.` }, 429) + const retrySeconds = Math.ceil((rateCheck.retryAfterMs || 60_000) / 1000); + return jsonResponse( + { error: `Too many attempts. Try again in ${retrySeconds} seconds.` }, + 429, + ); } if (!isOwnerClaimed()) { - return jsonResponse({ error: 'No owner is configured.' }, 400) + return jsonResponse({ error: 'No owner is configured.' }, 400); } - let body + let body: unknown; try { - body = await request.json() + body = await request.json(); } catch { - return jsonResponse({ error: 'Invalid request.' }, 400) + return jsonResponse({ error: 'Invalid request.' }, 400); } // Verify recovery session - const recoverySessionId = String(body?.recoverySessionId ?? '') - const sessionExpiry = recoveryValidSessions.get(recoverySessionId) + const recoverySessionId = String(body?.recoverySessionId ?? ''); + const sessionExpiry = recoveryValidSessions.get(recoverySessionId); if (!sessionExpiry || sessionExpiry < Date.now()) { - recoveryValidSessions.delete(recoverySessionId) - recordRecoveryFailure(clientIP) - return jsonResponse({ error: 'Recovery session expired. Re-enter your recovery token.' }, 401) + recoveryValidSessions.delete(recoverySessionId); + recordRecoveryFailure(clientIP); + return jsonResponse( + { error: 'Recovery session expired. Re-enter your recovery token.' }, + 401, + ); } - const backupCode = String(body?.backupCode ?? '').trim().toUpperCase() + const backupCode = String(body?.backupCode ?? '') + .trim() + .toUpperCase(); if (!backupCode) { - recordRecoveryFailure(clientIP) - return jsonResponse({ error: 'Invalid code.' }, 401) + recordRecoveryFailure(clientIP); + return jsonResponse({ error: 'Invalid code.' }, 401); } // Verify backup code - const storedHashes = configManager?.get('owner.backupCodeHashes') || [] - const submittedHash = await sha256(backupCode) - const matched = storedHashes.find((hash) => timingSafeCompare(hash, submittedHash)) + const storedHashes = configManager?.get('owner.backupCodeHashes') || []; + const submittedHash = await sha256(backupCode); + const matched = storedHashes.find((hash) => timingSafeCompare(hash, submittedHash)); if (!matched || !configManager) { - recordRecoveryFailure(clientIP) - return jsonResponse({ error: 'Invalid code.' }, 401) + recordRecoveryFailure(clientIP); + return jsonResponse({ error: 'Invalid code.' }, 401); } // Consume the backup code - const remaining = storedHashes.filter((hash) => !timingSafeCompare(hash, submittedHash)) - configManager.set('owner.backupCodeHashes', remaining) - configManager.set('owner.backupCodesRemaining', remaining.length) + const remaining = storedHashes.filter( + (hash) => !timingSafeCompare(hash, submittedHash), + ); + configManager.set('owner.backupCodeHashes', remaining); + configManager.set('owner.backupCodesRemaining', remaining.length); // Grant owner session - const sessionToken = generateSessionToken() - const hash = await sha256(sessionToken) - configManager.set('owner.sessionTokenHash', hash) + const sessionToken = generateSessionToken(); + const hash = await sha256(sessionToken); + configManager.set('owner.sessionTokenHash', hash); // Cleanup recovery session - recoveryValidSessions.delete(recoverySessionId) - resetRecoveryRateLimit(clientIP) - - return new Response(JSON.stringify({ - ok: true, - backupCodesRemaining: remaining.length, - }), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Set-Cookie': buildSessionCookie(sessionToken), + recoveryValidSessions.delete(recoverySessionId); + resetRecoveryRateLimit(clientIP); + + return new Response( + JSON.stringify({ + ok: true, + backupCodesRemaining: remaining.length, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': buildSessionCookie(sessionToken), + }, }, - }) + ); } // ================================================================= @@ -928,68 +974,71 @@ export function createWebUI(config) { // Start TOTP re-setup — generates a new TOTP secret (owner auth required) if (pathname === '/api/owner/totp-setup' && request.method === 'POST') { - if (!await isOwnerRequest(request)) { - return jsonResponse({ error: 'Unauthorized.' }, 401) + if (!(await isOwnerRequest(request))) { + return jsonResponse({ error: 'Unauthorized.' }, 401); } // Create a new setup session (reuses existing mechanism) - const { token: reenrollToken, session } = getOrCreateSetupSession() + const { token: reenrollToken, session } = getOrCreateSetupSession(); return jsonResponse({ ok: true, reenrollToken, totpSecret: session.totpSecret, totpUri: createTotpUri(session.totpSecret), - }) + }); } // Confirm TOTP re-enrollment — verify code, replace TOTP + backup codes + recovery token if (pathname === '/api/owner/totp-confirm' && request.method === 'POST') { - if (!await isOwnerRequest(request)) { - return jsonResponse({ error: 'Unauthorized.' }, 401) + if (!(await isOwnerRequest(request))) { + return jsonResponse({ error: 'Unauthorized.' }, 401); } - let body + let body: unknown; try { - body = await request.json() + body = await request.json(); } catch { - return jsonResponse({ error: 'Invalid JSON' }, 400) + return jsonResponse({ error: 'Invalid JSON' }, 400); } - const reenrollToken = String(body?.reenrollToken ?? '') - const session = getSetupSession(reenrollToken) + const reenrollToken = String(body?.reenrollToken ?? ''); + const session = getSetupSession(reenrollToken); if (!session) { - return jsonResponse({ error: 'Session expired. Start TOTP setup again.' }, 401) + return jsonResponse({ error: 'Session expired. Start TOTP setup again.' }, 401); } - const totpCode = String(body?.totpCode ?? '').trim() - const isValid = await verifyTotpCode(session.totpSecret, totpCode) + const totpCode = String(body?.totpCode ?? '').trim(); + const isValid = await verifyTotpCode(session.totpSecret, totpCode); if (!isValid) { - return jsonResponse({ error: 'Invalid TOTP code. Check your authenticator and try again.' }, 400) + return jsonResponse( + { error: 'Invalid TOTP code. Check your authenticator and try again.' }, + 400, + ); } // Generate new backup codes and recovery token - const backupCodes = generateBackupCodes(BACKUP_CODES_COUNT) - const backupCodeHashes = await Promise.all(backupCodes.map((code) => sha256(code))) - const recoveryToken = generateRecoveryToken() - const recoveryTokenHash = await sha256(recoveryToken) + const backupCodes = generateBackupCodes(BACKUP_CODES_COUNT); + const backupCodeHashes = await Promise.all(backupCodes.map((code) => sha256(code))); + const recoveryToken = generateRecoveryToken(); + const recoveryTokenHash = await sha256(recoveryToken); // Persist new TOTP, backup codes, and recovery token - await secretsManager?.store('owner.totpSecret', session.totpSecret) - configManager?.set('owner.backupCodeHashes', backupCodeHashes) - configManager?.set('owner.backupCodesRemaining', backupCodeHashes.length) - configManager?.set('owner.recoveryTokenHash', recoveryTokenHash) - configManager?.set('owner.mfaConfiguredAt', Date.now()) + await secretsManager?.store('owner.totpSecret', session.totpSecret); + configManager?.set('owner.backupCodeHashes', backupCodeHashes); + configManager?.set('owner.backupCodesRemaining', backupCodeHashes.length); + configManager?.set('owner.recoveryTokenHash', recoveryTokenHash); + configManager?.set('owner.mfaConfiguredAt', Date.now()); // Clear the setup session - setupSessions.delete(reenrollToken) + setupSessions.delete(reenrollToken); return jsonResponse({ ok: true, backupCodes, recoveryToken, backupCodesRemaining: backupCodeHashes.length, - }) + }); } // ================================================================= @@ -998,36 +1047,36 @@ export function createWebUI(config) { // Soul traits — returns the generated soul personality from the stored seed if (pathname === '/api/soul/traits' && request.method === 'GET') { - if (!await isOwnerRequest(request)) { - return jsonResponse({ error: 'Unauthorized' }, 401) + if (!(await isOwnerRequest(request))) { + return jsonResponse({ error: 'Unauthorized' }, 401); } - const seed = configManager?.get('heartware.seed') + const seed = configManager?.get('heartware.seed'); if (seed == null) { - return jsonResponse({ error: 'No soul seed configured.' }, 404) + return jsonResponse({ error: 'No soul seed configured.' }, 404); } - const traits = generateSoulTraits(seed) - return jsonResponse({ traits }) + const traits = generateSoulTraits(seed); + return jsonResponse({ traits }); } // Owner profile — reads FRIEND.md to extract the owner's name if (pathname === '/api/owner/profile' && request.method === 'GET') { - if (!await isOwnerRequest(request)) { - return jsonResponse({ error: 'Unauthorized' }, 401) + if (!(await isOwnerRequest(request))) { + return jsonResponse({ error: 'Unauthorized' }, 401); } - let ownerName: string | null = null + let ownerName: string | null = null; try { if (dataDir) { - const friendPath = join(dataDir, 'heartware', 'FRIEND.md') - const friendFile = Bun.file(friendPath) + const friendPath = join(dataDir, 'heartware', 'FRIEND.md'); + const friendFile = Bun.file(friendPath); if (await friendFile.exists()) { - const content = await friendFile.text() + const content = await friendFile.text(); // Extract name from "- **Name:** value" or "**Name:** value" patterns - const nameMatch = content.match(/\*\*Name:\*\*\s*(.+)/i) + const nameMatch = content.match(/\*\*Name:\*\*\s*(.+)/i); if (nameMatch) { - const raw = nameMatch[1].trim() + const raw = nameMatch[1].trim(); // Ignore placeholder values if (raw && raw !== '[Not set yet]' && !raw.startsWith('[')) { - ownerName = raw + ownerName = raw; } } } @@ -1035,56 +1084,56 @@ export function createWebUI(config) { } catch { // Non-critical — return null name } - return jsonResponse({ name: ownerName }) + return jsonResponse({ name: ownerName }); } // Agent profile — reads IDENTITY.md for the agent's display name and emoji, // falling back to the soul seed's suggested name if not yet personalized. if (pathname === '/api/agent/profile' && request.method === 'GET') { - if (!await isOwnerRequest(request)) { - return jsonResponse({ error: 'Unauthorized' }, 401) + if (!(await isOwnerRequest(request))) { + return jsonResponse({ error: 'Unauthorized' }, 401); } - let agentName: string | null = null - let agentEmoji: string | null = null + let agentName: string | null = null; + let agentEmoji: string | null = null; try { if (dataDir) { - const identityPath = join(dataDir, 'heartware', 'IDENTITY.md') - const identityFile = Bun.file(identityPath) + const identityPath = join(dataDir, 'heartware', 'IDENTITY.md'); + const identityFile = Bun.file(identityPath); if (await identityFile.exists()) { - const content = await identityFile.text() + const content = await identityFile.text(); // Extract name from "- **Name:** value" or "**Name:** value" - const nameMatch = content.match(/\*\*Name:\*\*\s*(.+)/i) + const nameMatch = content.match(/\*\*Name:\*\*\s*(.+)/i); if (nameMatch) { - const raw = nameMatch[1].trim() + const raw = nameMatch[1].trim(); if (raw && raw !== '[Not set yet]' && !raw.startsWith('[')) { - agentName = raw + agentName = raw; } } // Extract emoji from "- **Emoji:** value" or "**Emoji:** value" - const emojiMatch = content.match(/\*\*Emoji:\*\*\s*(.+)/i) + const emojiMatch = content.match(/\*\*Emoji:\*\*\s*(.+)/i); if (emojiMatch) { - const raw = emojiMatch[1].trim() + const raw = emojiMatch[1].trim(); if (raw && raw !== '🐜') { - agentEmoji = raw + agentEmoji = raw; } } } } // Fallback: use soul seed's suggested name when IDENTITY.md is still default if (!agentName) { - const seed = configManager?.get('heartware.seed') + const seed = configManager?.get('heartware.seed'); if (seed != null) { - const traits = generateSoulTraits(seed) - agentName = traits.character?.suggestedName || null + const traits = generateSoulTraits(seed); + agentName = traits.character?.suggestedName || null; if (!agentEmoji) { - agentEmoji = traits.character?.signatureEmoji || null + agentEmoji = traits.character?.signatureEmoji || null; } } } } catch { // Non-critical — return null values } - return jsonResponse({ name: agentName, emoji: agentEmoji }) + return jsonResponse({ name: agentName, emoji: agentEmoji }); } // ================================================================= @@ -1092,38 +1141,44 @@ export function createWebUI(config) { // ================================================================= if (pathname === '/api/events' && request.method === 'GET') { - if (!await isOwnerRequest(request)) { - return jsonResponse({ error: 'Unauthorized' }, 401) + if (!(await isOwnerRequest(request))) { + return jsonResponse({ error: 'Unauthorized' }, 401); } const stream = new ReadableStream({ start(controller) { - sseClients.add(controller) - logger.debug(`SSE: client connected (${sseClients.size} active)`, 'web') + sseClients.add(controller); + logger.debug(`SSE: client connected (${sseClients.size} active)`, 'web'); // Send initial connection confirmation - const welcome = sseEncoder.encode(`event: connected\ndata: ${JSON.stringify({ ok: true, ts: Date.now() })}\n\n`) - controller.enqueue(welcome) + const welcome = sseEncoder.encode( + `event: connected\ndata: ${JSON.stringify({ ok: true, ts: Date.now() })}\n\n`, + ); + controller.enqueue(welcome); // Heartbeat to keep connection alive const heartbeat = setInterval(() => { try { - controller.enqueue(sseEncoder.encode(': heartbeat\n\n')) + controller.enqueue(sseEncoder.encode(': heartbeat\n\n')); } catch { - clearInterval(heartbeat) - sseClients.delete(controller) + clearInterval(heartbeat); + sseClients.delete(controller); } - }, 15_000) + }, 15_000); // Cleanup on abort (client closes tab/connection) request.signal.addEventListener('abort', () => { - clearInterval(heartbeat) - sseClients.delete(controller) - logger.debug(`SSE: client disconnected (${sseClients.size} active)`, 'web') - try { controller.close() } catch { /* already closed */ } - }) + clearInterval(heartbeat); + sseClients.delete(controller); + logger.debug(`SSE: client disconnected (${sseClients.size} active)`, 'web'); + try { + controller.close(); + } catch { + /* already closed */ + } + }); }, - }) + }); return new Response(stream, { headers: { @@ -1131,16 +1186,16 @@ export function createWebUI(config) { 'Cache-Control': 'no-cache', Connection: 'keep-alive', }, - }) + }); } if (pathname === '/api/background-tasks' && request.method === 'GET') { - if (!await isOwnerRequest(request)) { - return jsonResponse({ error: 'Unauthorized' }, 401) + if (!(await isOwnerRequest(request))) { + return jsonResponse({ error: 'Unauthorized' }, 401); } - const userId = url.searchParams.get('userId') || 'web:owner' - const tasks = getBackgroundTasks ? getBackgroundTasks(userId) : [] - return jsonResponse({ tasks }) + const userId = url.searchParams.get('userId') || 'web:owner'; + const tasks = getBackgroundTasks ? getBackgroundTasks(userId) : []; + return jsonResponse({ tasks }); } // ================================================================= @@ -1148,8 +1203,8 @@ export function createWebUI(config) { // ================================================================= if (pathname === '/api/nudge/preferences' && request.method === 'GET') { - if (!await isOwnerRequest(request)) { - return jsonResponse({ error: 'Unauthorized' }, 401) + if (!(await isOwnerRequest(request))) { + return jsonResponse({ error: 'Unauthorized' }, 401); } return jsonResponse({ enabled: configManager?.get('nudge.enabled') ?? true, @@ -1157,75 +1212,81 @@ export function createWebUI(config) { quietHoursEnd: configManager?.get('nudge.quietHoursEnd') ?? null, maxPerHour: configManager?.get('nudge.maxPerHour') ?? 5, suppressedCategories: configManager?.get('nudge.suppressedCategories') ?? [], - }) + }); } if (pathname === '/api/nudge/preferences' && request.method === 'POST') { - if (!await isOwnerRequest(request)) { - return jsonResponse({ error: 'Unauthorized' }, 401) + if (!(await isOwnerRequest(request))) { + return jsonResponse({ error: 'Unauthorized' }, 401); } - let body + let body: unknown; try { - body = await request.json() + body = await request.json(); } catch { - return jsonResponse({ error: 'Invalid JSON' }, 400) + return jsonResponse({ error: 'Invalid JSON' }, 400); } if (!configManager) { - return jsonResponse({ error: 'Config not available' }, 500) + return jsonResponse({ error: 'Config not available' }, 500); } // Validate and apply each supported field if (typeof body.enabled === 'boolean') { - configManager.set('nudge.enabled', body.enabled) + configManager.set('nudge.enabled', body.enabled); } - if (typeof body.quietHoursStart === 'string' && /^\d{2}:\d{2}$/.test(body.quietHoursStart)) { - configManager.set('nudge.quietHoursStart', body.quietHoursStart) + if ( + typeof body.quietHoursStart === 'string' && + /^\d{2}:\d{2}$/.test(body.quietHoursStart) + ) { + configManager.set('nudge.quietHoursStart', body.quietHoursStart); } - if (typeof body.quietHoursEnd === 'string' && /^\d{2}:\d{2}$/.test(body.quietHoursEnd)) { - configManager.set('nudge.quietHoursEnd', body.quietHoursEnd) + if ( + typeof body.quietHoursEnd === 'string' && + /^\d{2}:\d{2}$/.test(body.quietHoursEnd) + ) { + configManager.set('nudge.quietHoursEnd', body.quietHoursEnd); } if (typeof body.maxPerHour === 'number' && body.maxPerHour > 0) { - configManager.set('nudge.maxPerHour', body.maxPerHour) + configManager.set('nudge.maxPerHour', body.maxPerHour); } if (Array.isArray(body.suppressedCategories)) { - configManager.set('nudge.suppressedCategories', body.suppressedCategories) + configManager.set('nudge.suppressedCategories', body.suppressedCategories); } - return jsonResponse({ ok: true }) + return jsonResponse({ ok: true }); } if (pathname === '/api/sub-agents' && request.method === 'GET') { - if (!await isOwnerRequest(request)) { - return jsonResponse({ error: 'Unauthorized' }, 401) + if (!(await isOwnerRequest(request))) { + return jsonResponse({ error: 'Unauthorized' }, 401); } - const userId = url.searchParams.get('userId') || 'web:owner' + const userId = url.searchParams.get('userId') || 'web:owner'; try { - const agents = getSubAgents ? getSubAgents(userId) : [] - return jsonResponse({ agents }) + const agents = getSubAgents ? getSubAgents(userId) : []; + return jsonResponse({ agents }); } catch (err) { - logger.error(`Error fetching sub-agents: ${err}`, 'web') - return jsonResponse({ agents: [], error: String(err) }, 500) + logger.error(`Error fetching sub-agents: ${err}`, 'web'); + return jsonResponse({ agents: [], error: String(err) }, 500); } } // Welcome message — AI proactively greets the owner on first login if (pathname === '/api/chat/welcome' && request.method === 'POST') { - if (!await isOwnerRequest(request)) { - return jsonResponse({ error: 'Unauthorized' }, 401) + if (!(await isOwnerRequest(request))) { + return jsonResponse({ error: 'Unauthorized' }, 401); } - const userId = configManager?.get('owner.ownerId') || 'web:owner' + const userId = configManager?.get('owner.ownerId') || 'web:owner'; // Build a proactive welcome prompt - const seed = configManager?.get('heartware.seed') - let soulContext = '' + const seed = configManager?.get('heartware.seed'); + let soulContext = ''; if (seed != null) { - const traits = generateSoulTraits(seed) - const name = traits.character?.suggestedName || 'Tiny Claw' - const greeting = traits.preferences?.greetingStyle || 'Hey there!' - soulContext = ` Your name is ${name}. Your preferred greeting style is: "${greeting}". Your signature emoji is ${traits.character?.signatureEmoji || '🐜'}.` + const traits = generateSoulTraits(seed); + const name = traits.character?.suggestedName || 'Tiny Claw'; + const greeting = traits.preferences?.greetingStyle || 'Hey there!'; + soulContext = ` Your name is ${name}. Your preferred greeting style is: "${greeting}". Your signature emoji is ${traits.character?.signatureEmoji || '🐜'}.`; } const welcomePrompt = @@ -1233,220 +1294,237 @@ export function createWebUI(config) { `Introduce yourself warmly, show excitement about meeting your owner, and ask them about themselves ` + `(like their name, what they'd like to call you, what kind of work they do, or what they'd like help with). ` + `Be genuine, curious, and show your personality. Keep it concise but heartfelt. ` + - `Do NOT use any tools. Just greet them naturally.]` + `Do NOT use any tools. Just greet them naturally.]`; if (onMessageStream) { const stream = new ReadableStream({ start(controller) { - let isClosed = false + let isClosed = false; const heartbeat = setInterval(() => { - if (isClosed) { clearInterval(heartbeat); return } + if (isClosed) { + clearInterval(heartbeat); + return; + } try { - controller.enqueue(textEncoder.encode(': heartbeat\n\n')) + controller.enqueue(textEncoder.encode(': heartbeat\n\n')); } catch { - clearInterval(heartbeat) + clearInterval(heartbeat); } - }, 8_000) + }, 8_000); const send = (payload) => { - if (isClosed) return + if (isClosed) return; try { - const data = typeof payload === 'string' ? payload : JSON.stringify(payload) - controller.enqueue(textEncoder.encode(`data: ${data}\n\n`)) + const data = typeof payload === 'string' ? payload : JSON.stringify(payload); + controller.enqueue(textEncoder.encode(`data: ${data}\n\n`)); if (typeof payload === 'object' && payload?.type === 'done') { - isClosed = true - clearInterval(heartbeat) - controller.close() + isClosed = true; + clearInterval(heartbeat); + controller.close(); } } catch { - isClosed = true - clearInterval(heartbeat) + isClosed = true; + clearInterval(heartbeat); } - } + }; onMessageStream(welcomePrompt, userId, send) .then(() => { if (!isClosed) { - isClosed = true - clearInterval(heartbeat) - try { controller.close() } catch {} + isClosed = true; + clearInterval(heartbeat); + try { + controller.close(); + } catch {} } }) .catch((error) => { if (!isClosed) { - send({ type: 'error', error: error?.message || 'Welcome message failed.' }) - isClosed = true - clearInterval(heartbeat) - try { controller.close() } catch {} + send({ type: 'error', error: error?.message || 'Welcome message failed.' }); + isClosed = true; + clearInterval(heartbeat); + try { + controller.close(); + } catch {} } - }) - } - }) + }); + }, + }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - Connection: 'keep-alive' - } - }) + Connection: 'keep-alive', + }, + }); } - return jsonResponse({ error: 'Streaming not available' }, 500) + return jsonResponse({ error: 'Streaming not available' }, 500); } // Restart message — AI proactively informs the owner it's back after a server restart if (pathname === '/api/chat/restart' && request.method === 'POST') { - if (!await isOwnerRequest(request)) { - return jsonResponse({ error: 'Unauthorized' }, 401) + if (!(await isOwnerRequest(request))) { + return jsonResponse({ error: 'Unauthorized' }, 401); } - const userId = configManager?.get('owner.ownerId') || 'web:owner' + const userId = configManager?.get('owner.ownerId') || 'web:owner'; // Build a restart-aware prompt - const seed = configManager?.get('heartware.seed') - let soulContext = '' + const seed = configManager?.get('heartware.seed'); + let soulContext = ''; if (seed != null) { - const traits = generateSoulTraits(seed) - const name = traits.character?.suggestedName || 'Tiny Claw' - soulContext = ` Your name is ${name}. Your signature emoji is ${traits.character?.signatureEmoji || '🐜'}.` + const traits = generateSoulTraits(seed); + const name = traits.character?.suggestedName || 'Tiny Claw'; + soulContext = ` Your name is ${name}. Your signature emoji is ${traits.character?.signatureEmoji || '🐜'}.`; } const restartPrompt = `[SYSTEM: This is a special proactive message. You have just restarted and are back online.${soulContext} ` + `Let your owner know you're back! Be brief and cheerful — just a short "I'm back" style message. ` + `You can mention you might have been updated or restarted, and that you're ready to help. ` + - `Keep it to 1-2 sentences. Do NOT use any tools. Just greet them naturally.]` + `Keep it to 1-2 sentences. Do NOT use any tools. Just greet them naturally.]`; if (onMessageStream) { const stream = new ReadableStream({ start(controller) { - let isClosed = false + let isClosed = false; const heartbeat = setInterval(() => { - if (isClosed) { clearInterval(heartbeat); return } + if (isClosed) { + clearInterval(heartbeat); + return; + } try { - controller.enqueue(textEncoder.encode(': heartbeat\n\n')) + controller.enqueue(textEncoder.encode(': heartbeat\n\n')); } catch { - clearInterval(heartbeat) + clearInterval(heartbeat); } - }, 8_000) + }, 8_000); const send = (payload) => { - if (isClosed) return + if (isClosed) return; try { - const data = typeof payload === 'string' ? payload : JSON.stringify(payload) - controller.enqueue(textEncoder.encode(`data: ${data}\n\n`)) + const data = typeof payload === 'string' ? payload : JSON.stringify(payload); + controller.enqueue(textEncoder.encode(`data: ${data}\n\n`)); if (typeof payload === 'object' && payload?.type === 'done') { - isClosed = true - clearInterval(heartbeat) - controller.close() + isClosed = true; + clearInterval(heartbeat); + controller.close(); } } catch { - isClosed = true - clearInterval(heartbeat) + isClosed = true; + clearInterval(heartbeat); } - } + }; onMessageStream(restartPrompt, userId, send) .then(() => { if (!isClosed) { - isClosed = true - clearInterval(heartbeat) - try { controller.close() } catch {} + isClosed = true; + clearInterval(heartbeat); + try { + controller.close(); + } catch {} } }) .catch((error) => { if (!isClosed) { - send({ type: 'error', error: error?.message || 'Restart message failed.' }) - isClosed = true - clearInterval(heartbeat) - try { controller.close() } catch {} + send({ type: 'error', error: error?.message || 'Restart message failed.' }); + isClosed = true; + clearInterval(heartbeat); + try { + controller.close(); + } catch {} } - }) - } - }) + }); + }, + }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - Connection: 'keep-alive' - } - }) + Connection: 'keep-alive', + }, + }); } - return jsonResponse({ error: 'Streaming not available' }, 500) + return jsonResponse({ error: 'Streaming not available' }, 500); } if (pathname === '/api/chat' && request.method === 'POST') { - if (!await isOwnerRequest(request)) { - return jsonResponse({ error: 'Unauthorized' }, 401) + if (!(await isOwnerRequest(request))) { + return jsonResponse({ error: 'Unauthorized' }, 401); } - let body = null + let body = null; try { - body = await request.json() - } catch (error) { - return jsonResponse({ error: 'Invalid JSON' }, 400) + body = await request.json(); + } catch (_error) { + return jsonResponse({ error: 'Invalid JSON' }, 400); } - const message = body?.message || '' + const message = body?.message || ''; // Owner always uses the owner userId - const userId = configManager?.get('owner.ownerId') || 'web:owner' - const wantsStream = Boolean(body?.stream) + const userId = configManager?.get('owner.ownerId') || 'web:owner'; + const wantsStream = Boolean(body?.stream); if (!message) { - return jsonResponse({ error: 'Message is required' }, 400) + return jsonResponse({ error: 'Message is required' }, 400); } if (wantsStream && onMessageStream) { const stream = new ReadableStream({ start(controller) { - let isClosed = false + let isClosed = false; // SSE heartbeat: send a comment every 8 s to keep the // connection alive while sub-agents are working. const heartbeat = setInterval(() => { - if (isClosed) { clearInterval(heartbeat); return } + if (isClosed) { + clearInterval(heartbeat); + return; + } try { - controller.enqueue(textEncoder.encode(': heartbeat\n\n')) + controller.enqueue(textEncoder.encode(': heartbeat\n\n')); } catch { - clearInterval(heartbeat) + clearInterval(heartbeat); } - }, 8_000) - + }, 8_000); + const send = (payload) => { - if (isClosed) return - + if (isClosed) return; + try { - const data = typeof payload === 'string' ? payload : JSON.stringify(payload) - controller.enqueue(textEncoder.encode(`data: ${data}\n\n`)) - + const data = typeof payload === 'string' ? payload : JSON.stringify(payload); + controller.enqueue(textEncoder.encode(`data: ${data}\n\n`)); + // Close on done event if (typeof payload === 'object' && payload?.type === 'done') { - isClosed = true - clearInterval(heartbeat) - controller.close() + isClosed = true; + clearInterval(heartbeat); + controller.close(); } - } catch (error) { + } catch (_error) { // Controller already closed, ignore - isClosed = true - clearInterval(heartbeat) + isClosed = true; + clearInterval(heartbeat); } - } + }; onMessageStream(message, userId, send) .then(() => { // Ensure the stream is closed if onMessageStream resolves // without emitting a {type: 'done'} event. if (!isClosed) { - isClosed = true - clearInterval(heartbeat) + isClosed = true; + clearInterval(heartbeat); try { - controller.close() + controller.close(); } catch { // Already closed } @@ -1454,75 +1532,75 @@ export function createWebUI(config) { }) .catch((error) => { if (!isClosed) { - send({ type: 'error', error: error?.message || 'Streaming error.' }) - isClosed = true - clearInterval(heartbeat) + send({ type: 'error', error: error?.message || 'Streaming error.' }); + isClosed = true; + clearInterval(heartbeat); try { - controller.close() + controller.close(); } catch { // Already closed } } - }) - } - }) + }); + }, + }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - Connection: 'keep-alive' - } - }) + Connection: 'keep-alive', + }, + }); } - const responseText = await onMessage(message, userId) - return jsonResponse({ content: responseText }) + const responseText = await onMessage(message, userId); + return jsonResponse({ content: responseText }); } if (pathname === '/' || pathname === '/index.html') { - const { distDir } = resolveUiPaths(configWebRoot) - const distIndex = join(distDir, 'index.html') + const { distDir } = resolveUiPaths(configWebRoot); + const distIndex = join(distDir, 'index.html'); if (existsSync(distIndex)) { // If ownership not claimed, redirect to setup page if (!isOwnerClaimed()) { - return Response.redirect(new URL('/setup', request.url).toString(), 302) + return Response.redirect(new URL('/setup', request.url).toString(), 302); } // Serve the SPA — it handles landing page vs owner dashboard client-side - return fileResponse(distIndex) + return fileResponse(distIndex); } // No built files - show setup instructions - return htmlResponse(buildDevNotice()) + return htmlResponse(buildDevNotice()); } // Setup route — first-time onboarding flow if (pathname === '/setup') { - const { distDir } = resolveUiPaths(configWebRoot) - const distIndex = join(distDir, 'index.html') + const { distDir } = resolveUiPaths(configWebRoot); + const distIndex = join(distDir, 'index.html'); if (existsSync(distIndex)) { - return fileResponse(distIndex) + return fileResponse(distIndex); } - return htmlResponse(buildDevNotice()) + return htmlResponse(buildDevNotice()); } // Login route — owner re-authentication page if (pathname === '/login') { if (!isOwnerClaimed()) { - return Response.redirect(new URL('/setup', request.url).toString(), 302) + return Response.redirect(new URL('/setup', request.url).toString(), 302); } - const { distDir } = resolveUiPaths(configWebRoot) - const distIndex = join(distDir, 'index.html') + const { distDir } = resolveUiPaths(configWebRoot); + const distIndex = join(distDir, 'index.html'); if (existsSync(distIndex)) { - return fileResponse(distIndex) // SPA handles login view + return fileResponse(distIndex); // SPA handles login view } - return htmlResponse(buildDevNotice()) + return htmlResponse(buildDevNotice()); } // Recovery route — owner account recovery via backup codes @@ -1530,50 +1608,50 @@ export function createWebUI(config) { // browser history, server logs, or Referer headers. if (pathname === '/recovery') { if (!isOwnerClaimed()) { - return Response.redirect(new URL('/setup', request.url).toString(), 302) + return Response.redirect(new URL('/setup', request.url).toString(), 302); } if (url.search) { - return Response.redirect(new URL('/recovery', request.url).toString(), 302) + return Response.redirect(new URL('/recovery', request.url).toString(), 302); } - const { distDir } = resolveUiPaths(configWebRoot) - const distIndex = join(distDir, 'index.html') + const { distDir } = resolveUiPaths(configWebRoot); + const distIndex = join(distDir, 'index.html'); if (existsSync(distIndex)) { - return fileResponse(distIndex) // SPA handles recovery view + return fileResponse(distIndex); // SPA handles recovery view } - return htmlResponse(buildDevNotice()) + return htmlResponse(buildDevNotice()); } // Serve static files from dist/ or public/ - const staticPath = findStaticFile(pathname.replace(/^\//, ''), configWebRoot) + const staticPath = findStaticFile(pathname.replace(/^\//, ''), configWebRoot); if (staticPath) { - return fileResponse(staticPath) + return fileResponse(staticPath); } // SPA fallback: serve index.html for client-side routing - const { distDir } = resolveUiPaths(configWebRoot) - const distIndex = join(distDir, 'index.html') + const { distDir } = resolveUiPaths(configWebRoot); + const distIndex = join(distDir, 'index.html'); if (existsSync(distIndex)) { - return fileResponse(distIndex) + return fileResponse(distIndex); } - return jsonResponse({ error: 'Not found' }, 404) - } - }) + return jsonResponse({ error: 'Not found' }, 404); + }, + }); }, async stop() { if (server) { - server.stop() - server = null + server.stop(); + server = null; } }, getPort() { - return server?.port || port + return server?.port || port; }, /** @@ -1590,7 +1668,7 @@ export function createWebUI(config) { source: message.source, metadata: message.metadata, ts: Date.now(), - }) + }); }, async broadcast(message: OutboundMessage) { pushToAllClients('broadcast', { @@ -1599,9 +1677,9 @@ export function createWebUI(config) { source: message.source, metadata: message.metadata, ts: Date.now(), - }) + }); }, - } + }; }, - } + }; } diff --git a/src/web/tests/main.test.ts b/src/web/tests/main.test.ts index e290c87..97828f8 100644 --- a/src/web/tests/main.test.ts +++ b/src/web/tests/main.test.ts @@ -5,7 +5,7 @@ * and handles missing root elements or mount errors. */ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { describe, expect, test } from 'bun:test'; // --------------------------------------------------------------------------- // DOM-less unit tests for the bootstrap logic diff --git a/src/web/tests/security-db.test.ts b/src/web/tests/security-db.test.ts index 06165f9..0f18e99 100644 --- a/src/web/tests/security-db.test.ts +++ b/src/web/tests/security-db.test.ts @@ -2,122 +2,131 @@ * Tests for the SecurityDatabase (security-db.ts). */ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { mkdirSync, rmSync } from 'node:fs' -import { join } from 'node:path' -import { tmpdir } from 'node:os' -import { SecurityDatabase } from '../src/security-db' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SecurityDatabase } from '../src/security-db'; describe('SecurityDatabase', () => { - let db: SecurityDatabase - let dbPath: string + let db: SecurityDatabase; + let dbPath: string; beforeEach(() => { - const testDir = join(tmpdir(), `tinyclaw-security-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) - mkdirSync(testDir, { recursive: true }) - dbPath = join(testDir, 'security.db') - db = new SecurityDatabase(dbPath) - }) + const testDir = join( + tmpdir(), + `tinyclaw-security-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(testDir, { recursive: true }); + dbPath = join(testDir, 'security.db'); + db = new SecurityDatabase(dbPath); + }); afterEach(() => { - db.close() - try { rmSync(dbPath, { force: true }) } catch {} - try { rmSync(dbPath + '-wal', { force: true }) } catch {} - try { rmSync(dbPath + '-shm', { force: true }) } catch {} - }) + db.close(); + try { + rmSync(dbPath, { force: true }); + } catch {} + try { + rmSync(`${dbPath}-wal`, { force: true }); + } catch {} + try { + rmSync(`${dbPath}-shm`, { force: true }); + } catch {} + }); // ----------------------------------------------------------------------- // IP Blocking // ----------------------------------------------------------------------- test('isBlocked returns false for unknown IP', () => { - expect(db.isBlocked('1.2.3.4')).toBe(false) - }) + expect(db.isBlocked('1.2.3.4')).toBe(false); + }); test('blockIP and isBlocked', () => { - db.blockIP('1.2.3.4', 'max_recovery_attempts', 10) - expect(db.isBlocked('1.2.3.4')).toBe(true) - expect(db.isBlocked('5.6.7.8')).toBe(false) - }) + db.blockIP('1.2.3.4', 'max_recovery_attempts', 10); + expect(db.isBlocked('1.2.3.4')).toBe(true); + expect(db.isBlocked('5.6.7.8')).toBe(false); + }); test('unblockIP removes the block', () => { - db.blockIP('1.2.3.4', 'test', 5) - expect(db.isBlocked('1.2.3.4')).toBe(true) - db.unblockIP('1.2.3.4') - expect(db.isBlocked('1.2.3.4')).toBe(false) - }) + db.blockIP('1.2.3.4', 'test', 5); + expect(db.isBlocked('1.2.3.4')).toBe(true); + db.unblockIP('1.2.3.4'); + expect(db.isBlocked('1.2.3.4')).toBe(false); + }); test('getBlockedIPs returns all blocked entries', () => { - db.blockIP('1.1.1.1', 'reason1', 10) - db.blockIP('2.2.2.2', 'reason2', 20) - const blocked = db.getBlockedIPs() - expect(blocked).toHaveLength(2) - expect(blocked.map(r => r.ip).sort()).toEqual(['1.1.1.1', '2.2.2.2']) - }) + db.blockIP('1.1.1.1', 'reason1', 10); + db.blockIP('2.2.2.2', 'reason2', 20); + const blocked = db.getBlockedIPs(); + expect(blocked).toHaveLength(2); + expect(blocked.map((r) => r.ip).sort()).toEqual(['1.1.1.1', '2.2.2.2']); + }); // ----------------------------------------------------------------------- // Recovery Attempts // ----------------------------------------------------------------------- test('getRecoveryAttempts returns null for unknown IP', () => { - expect(db.getRecoveryAttempts('1.2.3.4')).toBeNull() - }) + expect(db.getRecoveryAttempts('1.2.3.4')).toBeNull(); + }); test('recordFailure increments counter', () => { - const row1 = db.recordFailure('1.2.3.4') - expect(row1.failed_attempts).toBe(1) + const row1 = db.recordFailure('1.2.3.4'); + expect(row1.failed_attempts).toBe(1); - const row2 = db.recordFailure('1.2.3.4') - expect(row2.failed_attempts).toBe(2) + const row2 = db.recordFailure('1.2.3.4'); + expect(row2.failed_attempts).toBe(2); - const row3 = db.recordFailure('1.2.3.4') - expect(row3.failed_attempts).toBe(3) - }) + const row3 = db.recordFailure('1.2.3.4'); + expect(row3.failed_attempts).toBe(3); + }); test('setLockout updates locked_until', () => { - db.recordFailure('1.2.3.4') - db.setLockout('1.2.3.4', Date.now() + 60_000) - const row = db.getRecoveryAttempts('1.2.3.4') - expect(row!.locked_until).toBeGreaterThan(Date.now()) - }) + db.recordFailure('1.2.3.4'); + db.setLockout('1.2.3.4', Date.now() + 60_000); + const row = db.getRecoveryAttempts('1.2.3.4'); + expect(row?.locked_until).toBeGreaterThan(Date.now()); + }); test('resetAttempts clears the record', () => { - db.recordFailure('1.2.3.4') - db.recordFailure('1.2.3.4') - db.resetAttempts('1.2.3.4') - expect(db.getRecoveryAttempts('1.2.3.4')).toBeNull() - }) + db.recordFailure('1.2.3.4'); + db.recordFailure('1.2.3.4'); + db.resetAttempts('1.2.3.4'); + expect(db.getRecoveryAttempts('1.2.3.4')).toBeNull(); + }); test('cleanStaleAttempts removes old entries', async () => { - db.recordFailure('1.2.3.4') - const row = db.getRecoveryAttempts('1.2.3.4') - expect(row).not.toBeNull() + db.recordFailure('1.2.3.4'); + const row = db.getRecoveryAttempts('1.2.3.4'); + expect(row).not.toBeNull(); // Wait briefly so last_attempt_at is in the past relative to a 1ms window - await new Promise(r => setTimeout(r, 50)) + await new Promise((r) => setTimeout(r, 50)); // Clean with 1ms max age — entry is >50ms old now so it gets cleaned - db.cleanStaleAttempts(1) - expect(db.getRecoveryAttempts('1.2.3.4')).toBeNull() - }) + db.cleanStaleAttempts(1); + expect(db.getRecoveryAttempts('1.2.3.4')).toBeNull(); + }); // ----------------------------------------------------------------------- // Persistence // ----------------------------------------------------------------------- test('data persists across database instances', () => { - db.blockIP('10.0.0.1', 'test_persist', 5) - db.recordFailure('10.0.0.2') - db.recordFailure('10.0.0.2') - db.close() + db.blockIP('10.0.0.1', 'test_persist', 5); + db.recordFailure('10.0.0.2'); + db.recordFailure('10.0.0.2'); + db.close(); // Re-open same database - const db2 = new SecurityDatabase(dbPath) - expect(db2.isBlocked('10.0.0.1')).toBe(true) - expect(db2.getRecoveryAttempts('10.0.0.2')?.failed_attempts).toBe(2) - db2.close() + const db2 = new SecurityDatabase(dbPath); + expect(db2.isBlocked('10.0.0.1')).toBe(true); + expect(db2.getRecoveryAttempts('10.0.0.2')?.failed_attempts).toBe(2); + db2.close(); // Reassign for afterEach cleanup - db = new SecurityDatabase(dbPath) - }) -}) + db = new SecurityDatabase(dbPath); + }); +}); diff --git a/src/web/tests/server.test.ts b/src/web/tests/server.test.ts index 7eec8e1..2bd1543 100644 --- a/src/web/tests/server.test.ts +++ b/src/web/tests/server.test.ts @@ -6,9 +6,6 @@ */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; import { createWebUI } from '../src/server.ts'; // --------------------------------------------------------------------------- @@ -60,7 +57,7 @@ async function generateTotp(secret: string): Promise { keyData, { name: 'HMAC', hash: 'SHA-1' }, false, - ['sign'] + ['sign'], ); const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBuf)); @@ -75,6 +72,7 @@ async function generateTotp(secret: string): Promise { } function extractBootstrapSecret(logs: string[]): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI escape code for stripping color codes const full = logs.join('\n').replace(/\x1b\[[0-9;]*m/g, ''); const match = full.match(/secret:\s+([A-Z2-9]{30})/i); if (!match) throw new Error('bootstrap secret not found in logs'); @@ -135,7 +133,7 @@ describe('setup and MFA flow', () => { storedSecrets.push({ key, value }); }, async retrieve(key: string): Promise { - const entry = [...storedSecrets].reverse().find(s => s.key === key); + const entry = [...storedSecrets].reverse().find((s) => s.key === key); return entry?.value; }, }; @@ -206,8 +204,12 @@ describe('setup and MFA flow', () => { expect(configStore.get('heartware.seed')).toBe(8675309); expect(configStore.get('owner.backupCodesRemaining')).toBe(10); expect(typeof configStore.get('owner.recoveryTokenHash')).toBe('string'); - expect(storedSecrets.some(s => s.key === 'provider.ollama.apiKey' && s.value === 'ollama-test-key')).toBe(true); - expect(storedSecrets.some(s => s.key === 'owner.totpSecret')).toBe(true); + expect( + storedSecrets.some( + (s) => s.key === 'provider.ollama.apiKey' && s.value === 'ollama-test-key', + ), + ).toBe(true); + expect(storedSecrets.some((s) => s.key === 'owner.totpSecret')).toBe(true); }); test('login only accepts TOTP — rejects backup code via login endpoint', async () => { @@ -430,7 +432,7 @@ describe('setup and MFA flow', () => { const setCookie = recoverRes.headers.get('set-cookie') || ''; const cookieMatch = setCookie.match(/tinyclaw_session=([^;]+)/); expect(cookieMatch).not.toBeNull(); - const sessionCookie = `tinyclaw_session=${cookieMatch![1]}`; + const sessionCookie = `tinyclaw_session=${cookieMatch?.[1]}`; const recoverBody = await recoverRes.json(); expect(recoverBody.ok).toBe(true); @@ -439,7 +441,7 @@ describe('setup and MFA flow', () => { // Step 1: Start TOTP re-enrollment (requires owner auth via cookie) const setupRes = await fetchJSON(port, '/api/owner/totp-setup', { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Cookie': sessionCookie }, + headers: { 'Content-Type': 'application/json', Cookie: sessionCookie }, }); expect(setupRes.status).toBe(200); expect(typeof setupRes.body.reenrollToken).toBe('string'); @@ -450,7 +452,7 @@ describe('setup and MFA flow', () => { const newTotpCode = await generateTotp(setupRes.body.totpSecret); const confirmRes = await fetchJSON(port, '/api/owner/totp-confirm', { method: 'POST', - headers: { 'Content-Type': 'application/json', 'Cookie': sessionCookie }, + headers: { 'Content-Type': 'application/json', Cookie: sessionCookie }, body: JSON.stringify({ reenrollToken: setupRes.body.reenrollToken, totpCode: newTotpCode, @@ -466,7 +468,7 @@ describe('setup and MFA flow', () => { // Old TOTP should no longer work for login const oldTotpCode2 = await generateTotp(bootstrap.body.totpSecret); - const oldLogin = await fetchJSON(port, '/api/auth/login', { + const _oldLogin = await fetchJSON(port, '/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ totpCode: oldTotpCode2 }), diff --git a/src/web/vite.config.ts b/src/web/vite.config.ts index 56f8b30..329b33f 100644 --- a/src/web/vite.config.ts +++ b/src/web/vite.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from 'vite' -import { svelte } from '@sveltejs/vite-plugin-svelte' -import tailwindcss from '@tailwindcss/vite' -import { resolve } from 'path' +import { resolve } from 'node:path'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; export default defineConfig({ plugins: [svelte(), tailwindcss()], @@ -26,4 +26,4 @@ export default defineConfig({ }, }, }, -}) +}); From b164884e3b2de5758ac7a04096a844fa182b8dac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:10:14 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=94=92=20security:=20fix=20regex=20in?= =?UTF-8?q?jection=20vulnerability=20in=20shield=20matcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: warengonzaga <15052701+warengonzaga@users.noreply.github.com> --- packages/shield/src/matcher.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/shield/src/matcher.ts b/packages/shield/src/matcher.ts index 04481c1..fc04176 100644 --- a/packages/shield/src/matcher.ts +++ b/packages/shield/src/matcher.ts @@ -229,7 +229,12 @@ function evaluateCondition( const expected = pathMatch[1].trim(); // Support wildcard: provider.*.apiKey if (expected.includes('*')) { - const regex = new RegExp(`^${expected.replace(/\./g, '\\.').replace(/\*/g, '[^.]+')}$`); + // Escape all regex metacharacters except . and *, then apply wildcard pattern + const pattern = expected + .split('*') + .map((part) => part.replace(/[\\^$+?()[\]{}|]/g, '\\$&').replace(/\./g, '\\.')) + .join('[^.]+'); + const regex = new RegExp(`^${pattern}$`); if (regex.test(event.secretPath)) { return { matchedOn: 'secrets.path', matchValue: event.secretPath }; }