From 4e1ca403141d9c73c98ac493fa1eae24aca5da4e Mon Sep 17 00:00:00 2001 From: Jo-Tsu Date: Mon, 17 Aug 2026 09:36:38 +0800 Subject: [PATCH 1/3] feat: rebuild Link as the Smallink desktop platform --- .dockerignore | 18 - .env.example | 10 - .github/ISSUE_TEMPLATE/bug_report.yml | 19 +- .github/ISSUE_TEMPLATE/feature_request.yml | 19 +- .github/pull_request_template.md | 9 +- .github/workflows/ci.yml | 68 +- .github/workflows/release.yml | 153 + .gitignore | 74 +- .prettierignore | 8 - .prettierrc | 6 - CHANGELOG.md | 37 - CHANGES.md | 241 + CODE_OF_CONDUCT.md | 2 +- CONTRIBUTING.md | 20 +- Dockerfile | 25 - LICENSE | 2 +- NOTICE | 4 +- PROJECT_LAYOUT.md | 40 + README.en.md | 171 - README.md | 256 +- SECURITY.md | 18 +- THIRD_PARTY_LICENSES/OpenWorker-MIT.txt | 18 + THIRD_PARTY_NOTICES.md | 14 + TRADEMARKS.md | 4 +- agent/link-agent.mjs | 458 -- components.json | 22 - crawler_backend/app.py | 229 - db/init/001_schema.sql | 117 - deploy/aliyun/bootstrap.sh | 65 - deploy/aliyun/deploy-existing-ecs.sh | 17 - deploy/aliyun/open-tunnel.sh | 29 - design/brand/README.md | 20 + design/brand/smallink-mark.png | Bin 0 -> 252781 bytes design/brand/smallink-wordmark-source.png | Bin 0 -> 1080675 bytes docker-compose.yml | 65 - docs/aliyun-private-deployment.md | 83 - docs/assets/how-it-works.png | Bin 0 -> 283153 bytes docs/assets/link-data-overview.png | Bin 32900 -> 0 bytes docs/config.example.toml | 24 + ...rent-memory-product-logic-and-structure.md | 571 ++ docs/data-intake-prd.md | 156 - docs/data-intake-technical-design.md | 133 - docs/link-agent-client-prd.md | 289 - docs/link-agent-prd.md | 150 - docs/link-agent-technical-design.md | 169 - docs/link-memory-prd.md | 605 ++ docs/link-memory-technical-design.md | 1184 +++ docs/link-product-prd.md | 2560 ++++++ docs/link-technical-design.md | 1977 +++++ docs/local-stable-run.md | 29 - docs/minem-smallink-integration-blueprint.md | 1743 ++++ docs/project-module-prd.md | 384 + docs/smallink-agent-ai-foundation-guide.md | 796 ++ docs/smallink-app-center-minem-prd.md | 616 ++ ...llink-app-center-minem-technical-design.md | 964 +++ docs/smallink-app-center-minem-test-plan.md | 689 ++ docs/smallink-architecture-guide.md | 728 ++ docs/smallink-minem-connector-design.md | 477 ++ docs/smallink-next-version-prd.md | 1009 +++ docs/smallink-ui-design-system.md | 194 + eslint.config.js | 48 - link-agent-client/.gitignore | 29 - link-agent-client/.vscode/extensions.json | 3 - link-agent-client/README.md | 31 - link-agent-client/package-lock.json | 2137 ----- link-agent-client/package.json | 34 - link-agent-client/public/tauri.svg | 6 - link-agent-client/public/vite.svg | 1 - link-agent-client/src-tauri/.gitignore | 7 - link-agent-client/src-tauri/Cargo.toml | 36 - link-agent-client/src-tauri/build.rs | 3 - .../src-tauri/capabilities/default.json | 13 - link-agent-client/src-tauri/icons/128x128.png | Bin 3512 -> 0 bytes .../src-tauri/icons/128x128@2x.png | Bin 7012 -> 0 bytes link-agent-client/src-tauri/icons/32x32.png | Bin 974 -> 0 bytes .../src-tauri/icons/Square107x107Logo.png | Bin 2863 -> 0 bytes .../src-tauri/icons/Square142x142Logo.png | Bin 3858 -> 0 bytes .../src-tauri/icons/Square150x150Logo.png | Bin 3966 -> 0 bytes .../src-tauri/icons/Square284x284Logo.png | Bin 7737 -> 0 bytes .../src-tauri/icons/Square30x30Logo.png | Bin 903 -> 0 bytes .../src-tauri/icons/Square310x310Logo.png | Bin 8591 -> 0 bytes .../src-tauri/icons/Square44x44Logo.png | Bin 1299 -> 0 bytes .../src-tauri/icons/Square71x71Logo.png | Bin 2011 -> 0 bytes .../src-tauri/icons/Square89x89Logo.png | Bin 2468 -> 0 bytes .../src-tauri/icons/StoreLogo.png | Bin 1523 -> 0 bytes link-agent-client/src-tauri/icons/icon.icns | Bin 98451 -> 0 bytes link-agent-client/src-tauri/icons/icon.ico | Bin 86642 -> 0 bytes link-agent-client/src-tauri/icons/icon.png | Bin 14183 -> 0 bytes link-agent-client/src-tauri/src/lib.rs | 1297 --- link-agent-client/src-tauri/src/main.rs | 6 - link-agent-client/src-tauri/tauri.conf.json | 62 - link-agent-client/src/App.css | 916 --- link-agent-client/src/App.tsx | 855 -- link-agent-client/src/assets/react.svg | 1 - link-agent-client/src/main.tsx | 9 - link-agent-client/tsconfig.node.json | 10 - link-agent-client/vite.config.ts | 32 - link/__init__.py | 16 + package-lock.json | 7140 ----------------- package.json | 109 - packaging/.gitignore | 4 + packaging/build_dmg.sh | 292 + packaging/dmg-background.tiff | Bin 0 -> 32738 bytes packaging/entry_smallink_server.py | 12 + packaging/smallink-server.spec | 138 + public/downloads/.gitkeep | 1 - pyproject.toml | 67 + scripts/check_versions.py | 42 + scripts/connect_codex.sh | 16 + scripts/import-codex-history.mjs | 339 - smallink/__init__.py | 7 + smallink/agent.py | 370 + smallink/agents/__init__.py | 15 + smallink/agents/base.py | 44 + smallink/agents/chat.py | 23 + smallink/agents/code.py | 74 + smallink/agents/link.py | 53 + smallink/agents/registry.py | 24 + smallink/attachments.py | 99 + smallink/audit.py | 173 + smallink/automation/__init__.py | 18 + smallink/automation/models.py | 239 + smallink/automation/scheduler.py | 113 + smallink/automation/store.py | 225 + smallink/automation/tools.py | 233 + smallink/catalog.py | 180 + smallink/cli.py | 70 + smallink/cloud.py | 712 ++ smallink/config.py | 120 + smallink/connections.py | 181 + smallink/connectors/__init__.py | 78 + smallink/connectors/accounts.py | 184 + smallink/connectors/adapters.py | 480 ++ smallink/connectors/attribution.py | 72 + smallink/connectors/base.py | 184 + smallink/connectors/browser_automation.py | 577 ++ smallink/connectors/catalog_copy.py | 243 + smallink/connectors/cli.py | 196 + smallink/connectors/codex_client.py | 323 + smallink/connectors/config.py | 137 + smallink/connectors/descriptors.py | 1518 ++++ smallink/connectors/email_tools.py | 843 ++ smallink/connectors/experimental/__init__.py | 18 + smallink/connectors/fake.py | 57 + smallink/connectors/gateway.py | 231 + smallink/connectors/gcal_accounts.py | 127 + smallink/connectors/github_installs.py | 124 + smallink/connectors/github_relay.py | 202 + smallink/connectors/gmail_accounts.py | 185 + smallink/connectors/hubspot_portals.py | 190 + smallink/connectors/integration_tools.py | 5024 ++++++++++++ smallink/connectors/minem_client.py | 432 + smallink/connectors/parked.py | 91 + smallink/connectors/relay_client.py | 531 ++ smallink/connectors/senders.py | 216 + smallink/connectors/setup.py | 599 ++ smallink/connectors/slack_addr.py | 34 + smallink/connectors/slack_directory.py | 197 + smallink/connectors/sync_store.py | 181 + smallink/connectors/tool_defs.py | 1245 +++ smallink/connectors/tools.py | 348 + smallink/connectors/traex_client.py | 100 + smallink/conversations.py | 617 ++ smallink/engine.py | 1044 +++ smallink/environment.py | 77 + smallink/events.py | 39 + smallink/inbox.py | 368 + smallink/inbox_routing.py | 137 + smallink/interactions.py | 54 + smallink/mcp/__init__.py | 29 + smallink/mcp/client.py | 162 + smallink/mcp/config.py | 129 + smallink/mcp/oauth.py | 240 + smallink/mcp/tools.py | 91 + smallink/memory/__init__.py | 21 + smallink/memory/base.py | 81 + smallink/memory/governance.py | 630 ++ smallink/memory/pipeline.py | 280 + smallink/memory/retrieval.py | 59 + smallink/memory/sqlite_store.py | 208 + smallink/memory/tools.py | 64 + smallink/mentions.py | 90 + smallink/overrides.py | 89 + smallink/pdf_support.py | 251 + smallink/permissions.py | 238 + smallink/personas/__init__.py | 22 + smallink/personas/builtin/ops.md | 44 + smallink/personas/loading.py | 68 + smallink/personas/manifest.py | 265 + smallink/personas/registry.py | 414 + smallink/project.py | 40 + smallink/projects.py | 22 + smallink/providers/__init__.py | 44 + smallink/providers/anthropic_provider.py | 584 ++ smallink/providers/base.py | 103 + smallink/providers/capabilities.py | 72 + smallink/providers/errors.py | 57 + smallink/providers/gemini_provider.py | 524 ++ smallink/providers/matrix.py | 135 + smallink/providers/openai_provider.py | 511 ++ smallink/providers/registry.py | 486 ++ smallink/providers/router.py | 118 + smallink/providers/trae_provider.py | 398 + smallink/risk.py | 63 + smallink/roots.py | 75 + smallink/secrets.py | 302 + smallink/selfwake.py | 185 + smallink/sensory/__init__.py | 6 + smallink/sensory/models.py | 30 + smallink/sensory/store.py | 447 ++ smallink/server/__init__.py | 4 + smallink/server/app.py | 2196 +++++ smallink/server/manager.py | 4735 +++++++++++ smallink/server/routers/__init__.py | 1 + smallink/server/routers/memory.py | 114 + smallink/server/routers/prompts.py | 172 + smallink/server/run.py | 175 + smallink/sessions.py | 38 + smallink/skills/__init__.py | 21 + smallink/skills/base.py | 408 + smallink/skills/store.py | 326 + smallink/sqlite.py | 30 + smallink/subscriptions.py | 268 + smallink/task/__init__.py | 14 + smallink/task/models.py | 68 + smallink/task/observer.py | 48 + smallink/task/store.py | 679 ++ smallink/testing/__init__.py | 1 + smallink/testing/fake_slack/__init__.py | 10 + smallink/testing/fake_slack/__main__.py | 69 + smallink/testing/fake_slack/server.py | 510 ++ smallink/tools/__init__.py | 3 + smallink/tools/ask.py | 58 + smallink/tools/directories.py | 40 + smallink/tools/files.py | 113 + smallink/tools/git.py | 90 + smallink/tools/plan.py | 43 + smallink/tools/registry.py | 71 + smallink/tools/search.py | 184 + smallink/tools/shell.py | 600 ++ smallink/tools/subagent.py | 165 + smallink/tools/todo.py | 87 + smallink/tui/__init__.py | 3 + smallink/tui/app.py | 248 + smallink/unattended.py | 43 + smallink/unrouted.py | 67 + smallink/web/__init__.py | 28 + smallink/web/fetch.py | 181 + smallink/web/providers.py | 128 + smallink/web/tool.py | 94 + smallink/workspace_trust.py | 62 + src/assets/smallink-logo.webp | Bin 91550 -> 0 bytes src/components/top-nav.tsx | 50 - src/components/ui/accordion.tsx | 51 - src/components/ui/alert-dialog.tsx | 115 - src/components/ui/alert.tsx | 49 - src/components/ui/aspect-ratio.tsx | 5 - src/components/ui/avatar.tsx | 47 - src/components/ui/badge.tsx | 32 - src/components/ui/breadcrumb.tsx | 101 - src/components/ui/button.tsx | 49 - src/components/ui/calendar.tsx | 177 - src/components/ui/card.tsx | 55 - src/components/ui/carousel.tsx | 240 - src/components/ui/chart.tsx | 331 - src/components/ui/checkbox.tsx | 26 - src/components/ui/collapsible.tsx | 11 - src/components/ui/command.tsx | 143 - src/components/ui/context-menu.tsx | 187 - src/components/ui/dialog.tsx | 104 - src/components/ui/drawer.tsx | 98 - src/components/ui/dropdown-menu.tsx | 188 - src/components/ui/form.tsx | 171 - src/components/ui/hover-card.tsx | 27 - src/components/ui/input-otp.tsx | 69 - src/components/ui/input.tsx | 22 - src/components/ui/label.tsx | 21 - src/components/ui/menubar.tsx | 229 - src/components/ui/navigation-menu.tsx | 120 - src/components/ui/pagination.tsx | 98 - src/components/ui/popover.tsx | 31 - src/components/ui/progress.tsx | 25 - src/components/ui/radio-group.tsx | 36 - src/components/ui/resizable.tsx | 37 - src/components/ui/scroll-area.tsx | 44 - src/components/ui/select.tsx | 152 - src/components/ui/separator.tsx | 24 - src/components/ui/sheet.tsx | 122 - src/components/ui/sidebar.tsx | 744 -- src/components/ui/skeleton.tsx | 7 - src/components/ui/slider.tsx | 23 - src/components/ui/sonner.tsx | 23 - src/components/ui/switch.tsx | 27 - src/components/ui/table.tsx | 94 - src/components/ui/tabs.tsx | 53 - src/components/ui/textarea.tsx | 21 - src/components/ui/toggle-group.tsx | 57 - src/components/ui/toggle.tsx | 42 - src/components/ui/tooltip.tsx | 32 - src/hooks/use-mobile.tsx | 19 - src/lib/error-capture.ts | 27 - src/lib/error-page.ts | 30 - src/lib/error-reporting.ts | 9 - src/lib/server-db.ts | 1136 --- src/lib/utils.ts | 6 - src/routeTree.gen.ts | 141 - src/router.tsx | 16 - src/routes/README.md | 21 - src/routes/__root.tsx | 124 - src/routes/codex.tsx | 325 - src/routes/crawler.tsx | 922 --- src/routes/data.tsx | 361 - src/routes/index.tsx | 425 - src/routes/settings.tsx | 491 -- src/server.ts | 408 - src/start.ts | 22 - src/styles.css | 145 - stt/.gitignore | 1 + stt/Cargo.lock | 1559 ++++ stt/Cargo.toml | 17 + stt/src/lib.rs | 666 ++ surfaces/gui/.gitignore | 9 + surfaces/gui/README.md | 54 + surfaces/gui/assets/icon.png | Bin 0 -> 21710 bytes surfaces/gui/e2e/README.md | 69 + surfaces/gui/e2e/accounts-page.spec.ts | 24 + surfaces/gui/e2e/cloud-status-pending.spec.ts | 21 + surfaces/gui/e2e/fixtures.ts | 334 + surfaces/gui/e2e/gallery.spec.ts | 23 + .../gui/e2e/project-session-isolation.spec.ts | 95 + .../gui}/index.html | 11 +- surfaces/gui/package-lock.json | 6148 ++++++++++++++ surfaces/gui/package.json | 41 + surfaces/gui/playwright.config.ts | 34 + surfaces/gui/playwright.live.config.ts | 29 + surfaces/gui/postcss.config.js | 6 + surfaces/gui/src-tauri/.gitignore | 3 + .../gui}/src-tauri/Cargo.lock | 1234 +-- surfaces/gui/src-tauri/Cargo.toml | 25 + surfaces/gui/src-tauri/build.rs | 3 + surfaces/gui/src-tauri/entitlements.plist | 14 + surfaces/gui/src-tauri/icons/128x128.png | Bin 0 -> 10534 bytes surfaces/gui/src-tauri/icons/128x128@2x.png | Bin 0 -> 30093 bytes surfaces/gui/src-tauri/icons/32x32.png | Bin 0 -> 1655 bytes surfaces/gui/src-tauri/icons/64x64.png | Bin 0 -> 4060 bytes .../gui/src-tauri/icons/Square107x107Logo.png | Bin 0 -> 8154 bytes .../gui/src-tauri/icons/Square142x142Logo.png | Bin 0 -> 12236 bytes .../gui/src-tauri/icons/Square150x150Logo.png | Bin 0 -> 13199 bytes .../gui/src-tauri/icons/Square284x284Logo.png | Bin 0 -> 35427 bytes .../gui/src-tauri/icons/Square30x30Logo.png | Bin 0 -> 1530 bytes .../gui/src-tauri/icons/Square310x310Logo.png | Bin 0 -> 40539 bytes .../gui/src-tauri/icons/Square44x44Logo.png | Bin 0 -> 2491 bytes .../gui/src-tauri/icons/Square71x71Logo.png | Bin 0 -> 4717 bytes .../gui/src-tauri/icons/Square89x89Logo.png | Bin 0 -> 6267 bytes surfaces/gui/src-tauri/icons/StoreLogo.png | Bin 0 -> 2936 bytes .../android/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../icons/android/mipmap-hdpi/ic_launcher.png | Bin 0 -> 2748 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 14816 bytes .../android/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 3170 bytes .../icons/android/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2631 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 8316 bytes .../android/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 3102 bytes .../android/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 6468 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 22934 bytes .../mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 7541 bytes .../android/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 11373 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 43673 bytes .../mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 13041 bytes .../android/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 16725 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 69300 bytes .../mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 19365 bytes .../android/values/ic_launcher_background.xml | 4 + surfaces/gui/src-tauri/icons/icon.icns | Bin 0 -> 529784 bytes surfaces/gui/src-tauri/icons/icon.ico | Bin 0 -> 41041 bytes surfaces/gui/src-tauri/icons/icon.png | Bin 0 -> 92802 bytes .../src-tauri/icons/ios/AppIcon-20x20@1x.png | Bin 0 -> 900 bytes .../icons/ios/AppIcon-20x20@2x-1.png | Bin 0 -> 2262 bytes .../src-tauri/icons/ios/AppIcon-20x20@2x.png | Bin 0 -> 2262 bytes .../src-tauri/icons/ios/AppIcon-20x20@3x.png | Bin 0 -> 3782 bytes .../src-tauri/icons/ios/AppIcon-29x29@1x.png | Bin 0 -> 1460 bytes .../icons/ios/AppIcon-29x29@2x-1.png | Bin 0 -> 3634 bytes .../src-tauri/icons/ios/AppIcon-29x29@2x.png | Bin 0 -> 3634 bytes .../src-tauri/icons/ios/AppIcon-29x29@3x.png | Bin 0 -> 6254 bytes .../src-tauri/icons/ios/AppIcon-40x40@1x.png | Bin 0 -> 2262 bytes .../icons/ios/AppIcon-40x40@2x-1.png | Bin 0 -> 5603 bytes .../src-tauri/icons/ios/AppIcon-40x40@2x.png | Bin 0 -> 5603 bytes .../src-tauri/icons/ios/AppIcon-40x40@3x.png | Bin 0 -> 9899 bytes .../src-tauri/icons/ios/AppIcon-512@2x.png | Bin 0 -> 273426 bytes .../src-tauri/icons/ios/AppIcon-60x60@2x.png | Bin 0 -> 9899 bytes .../src-tauri/icons/ios/AppIcon-60x60@3x.png | Bin 0 -> 17787 bytes .../src-tauri/icons/ios/AppIcon-76x76@1x.png | Bin 0 -> 5242 bytes .../src-tauri/icons/ios/AppIcon-76x76@2x.png | Bin 0 -> 13832 bytes .../icons/ios/AppIcon-83.5x83.5@2x.png | Bin 0 -> 15870 bytes surfaces/gui/src-tauri/src/lib.rs | 738 ++ surfaces/gui/src-tauri/src/main.rs | 6 + surfaces/gui/src-tauri/tauri.conf.json | 46 + surfaces/gui/src/App.tsx | 1972 +++++ surfaces/gui/src/api.auth.test.ts | 121 + surfaces/gui/src/api.ts | 2532 ++++++ surfaces/gui/src/assets/smallink-mark.png | Bin 0 -> 33108 bytes surfaces/gui/src/attach.ts | 31 + surfaces/gui/src/components/AccessSection.tsx | 609 ++ surfaces/gui/src/components/AddFolderForm.tsx | 89 + .../gui/src/components/AgentsView.test.tsx | 136 + surfaces/gui/src/components/AgentsView.tsx | 918 +++ .../gui/src/components/AppErrorBoundary.tsx | 56 + .../gui/src/components/ApprovalCard.test.tsx | 222 + surfaces/gui/src/components/ApprovalCard.tsx | 315 + surfaces/gui/src/components/AsyncFeedback.tsx | 92 + surfaces/gui/src/components/AuditView.tsx | 144 + .../src/components/AutomationQuickstart.tsx | 634 ++ .../src/components/CompactSidebar.test.tsx | 32 + .../gui/src/components/CompactSidebar.tsx | 82 + surfaces/gui/src/components/Composer.tsx | 689 ++ .../src/components/Composer.voice.test.tsx | 104 + surfaces/gui/src/components/ConfirmDialog.tsx | 66 + .../components/ConnectorMessageCard.test.tsx | 86 + .../src/components/ConnectorMessageCard.tsx | 104 + .../gui/src/components/CreateProjectModal.tsx | 117 + .../src/components/DirectoryRequestCard.tsx | 60 + surfaces/gui/src/components/Dropdown.tsx | 59 + surfaces/gui/src/components/FolderGate.tsx | 94 + surfaces/gui/src/components/GalleryModal.tsx | 471 ++ surfaces/gui/src/components/Icon.tsx | 372 + .../gui/src/components/InboxConfigure.tsx | 384 + surfaces/gui/src/components/InboxItemCard.tsx | 211 + surfaces/gui/src/components/InboxView.tsx | 330 + .../src/components/IntegrationsView.test.tsx | 59 + .../gui/src/components/IntegrationsView.tsx | 103 + surfaces/gui/src/components/ManageTabs.tsx | 953 +++ surfaces/gui/src/components/Markdown.test.tsx | 65 + surfaces/gui/src/components/Markdown.tsx | 124 + .../gui/src/components/MemoryView.test.tsx | 207 + surfaces/gui/src/components/MemoryView.tsx | 903 +++ .../src/components/ModelChecklist.test.tsx | 107 + .../gui/src/components/ModelChecklist.tsx | 225 + surfaces/gui/src/components/Onboarding.tsx | 261 + surfaces/gui/src/components/PersonaHero.tsx | 57 + .../gui/src/components/PersonaView.test.tsx | 139 + surfaces/gui/src/components/PersonaView.tsx | 286 + surfaces/gui/src/components/PersonasTab.tsx | 275 + surfaces/gui/src/components/PlanCard.tsx | 71 + surfaces/gui/src/components/PromptsView.tsx | 194 + .../gui/src/components/RightRail.test.tsx | 67 + surfaces/gui/src/components/RightRail.tsx | 658 ++ surfaces/gui/src/components/RootRow.tsx | 63 + surfaces/gui/src/components/RunsView.test.tsx | 191 + surfaces/gui/src/components/RunsView.tsx | 477 ++ surfaces/gui/src/components/ScheduledView.tsx | 602 ++ surfaces/gui/src/components/SearchModal.tsx | 164 + surfaces/gui/src/components/SelectMenu.tsx | 113 + surfaces/gui/src/components/SessionIntro.tsx | 138 + surfaces/gui/src/components/SettingsView.tsx | 798 ++ surfaces/gui/src/components/Sidebar.test.tsx | 405 + surfaces/gui/src/components/Sidebar.tsx | 1709 ++++ surfaces/gui/src/components/SkillHub.test.tsx | 164 + surfaces/gui/src/components/SkillHub.tsx | 635 ++ .../gui/src/components/SubscriptionsChip.tsx | 331 + surfaces/gui/src/components/TodoPanel.tsx | 21 + surfaces/gui/src/components/Toggle.tsx | 31 + .../gui/src/components/Transcript.test.tsx | 208 + surfaces/gui/src/components/Transcript.tsx | 476 ++ .../src/components/WorkspaceTrustPrompt.tsx | 56 + surfaces/gui/src/components/brandIcons.tsx | 145 + .../components/connectors/AccountsDetail.tsx | 160 + .../connectors/AddConnectionModal.test.tsx | 252 + .../connectors/AddConnectionModal.tsx | 654 ++ .../components/connectors/AvailableDetail.tsx | 103 + .../components/connectors/CalendarDetail.tsx | 133 + .../src/components/connectors/CloudSignIn.tsx | 53 + .../components/connectors/ConnectorsList.tsx | 180 + .../connectors/ConnectorsSection.test.tsx | 108 + .../connectors/ConnectorsSection.tsx | 395 + .../components/connectors/GithubDetail.tsx | 368 + .../src/components/connectors/GmailDetail.tsx | 227 + .../components/connectors/HubSpotDetail.tsx | 197 + .../src/components/connectors/SlackDetail.tsx | 669 ++ .../components/connectors/SlackHowItWorks.tsx | 111 + .../components/connectors/ToolsDisclosure.tsx | 40 + surfaces/gui/src/components/connectors/ui.ts | 40 + .../src/components/connectors/visibility.ts | 12 + surfaces/gui/src/components/personaIcon.tsx | 62 + .../gui/src/connectors/ConnectorIcon.test.tsx | 68 + surfaces/gui/src/connectors/ConnectorIcon.tsx | 114 + surfaces/gui/src/connectors/registry.tsx | 270 + surfaces/gui/src/connectors/visuals.ts | 43 + surfaces/gui/src/flags.ts | 21 + surfaces/gui/src/fonts/manrope-700.woff2 | Bin 0 -> 14212 bytes surfaces/gui/src/humanize.ts | 166 + surfaces/gui/src/i18n.test.tsx | 47 + surfaces/gui/src/i18n.tsx | 1816 +++++ surfaces/gui/src/itemsFromMessages.test.ts | 96 + surfaces/gui/src/itemsFromMessages.ts | 101 + surfaces/gui/src/localPickerApi.ts | 22 + surfaces/gui/src/main.tsx | 30 + surfaces/gui/src/paths.ts | 4 + surfaces/gui/src/personaScope.ts | 29 + surfaces/gui/src/providers/ProviderSetup.tsx | 495 ++ surfaces/gui/src/providers/logos.ts | 60 + .../gui/src/providers/logos/anthropic.svg | 1 + surfaces/gui/src/providers/logos/deepseek.svg | 1 + .../gui/src/providers/logos/fireworks.svg | 1 + surfaces/gui/src/providers/logos/gemini.svg | 1 + surfaces/gui/src/providers/logos/kimi.svg | 1 + surfaces/gui/src/providers/logos/meta.svg | 1 + surfaces/gui/src/providers/logos/minimax.svg | 1 + surfaces/gui/src/providers/logos/mistral.svg | 1 + surfaces/gui/src/providers/logos/ollama.svg | 1 + surfaces/gui/src/providers/logos/openai.svg | 1 + surfaces/gui/src/providers/logos/qwen.svg | 1 + surfaces/gui/src/providers/logos/together.svg | 1 + surfaces/gui/src/providers/logos/xai.svg | 1 + surfaces/gui/src/providers/logos/zai.svg | 1 + surfaces/gui/src/streamGate.test.ts | 44 + surfaces/gui/src/streamGate.ts | 41 + surfaces/gui/src/styles.css | 2212 +++++ surfaces/gui/src/tailwind.css | 5 + surfaces/gui/src/tauri.test.ts | 23 + surfaces/gui/src/tauri.ts | 136 + surfaces/gui/src/theme.ts | 55 + surfaces/gui/src/types.ts | 137 + surfaces/gui/src/useRoots.ts | 92 + .../gui}/src/vite-env.d.ts | 0 surfaces/gui/src/windowDrag.test.ts | 23 + surfaces/gui/src/windowDrag.ts | 27 + surfaces/gui/tailwind.config.js | 47 + .../gui}/tsconfig.json | 4 - surfaces/gui/tsconfig.node.json | 14 + surfaces/gui/vite.config.ts | 64 + surfaces/gui/vitest.config.ts | 12 + tests/conftest.py | 36 + tests/test_accounts.py | 177 + tests/test_anthropic_provider.py | 684 ++ tests/test_attachments.py | 220 + tests/test_automation.py | 478 ++ tests/test_automation_create.py | 73 + tests/test_autotitle.py | 148 + tests/test_builtin_personas.py | 53 + tests/test_catalog.py | 115 + tests/test_cloud.py | 499 ++ tests/test_cloud_server.py | 221 + tests/test_code_tools.py | 203 + tests/test_codex_connector.py | 282 + tests/test_config.py | 140 + tests/test_connections.py | 246 + tests/test_connector_registry.py | 107 + tests/test_connectors.py | 1717 ++++ tests/test_connectors_allowlist.py | 103 + tests/test_dm_routing.py | 109 + tests/test_durable_resume.py | 124 + tests/test_email_tools.py | 370 + tests/test_engine.py | 444 + tests/test_engine_stop.py | 387 + tests/test_environment.py | 76 + tests/test_fake_slack.py | 348 + tests/test_gateway_inbox_reply.py | 66 + tests/test_gcal_accounts.py | 253 + tests/test_gemini_provider.py | 699 ++ tests/test_github_installs.py | 616 ++ tests/test_gmail_accounts.py | 296 + tests/test_hubspot_portals.py | 253 + tests/test_inbox.py | 129 + tests/test_inbox_routing.py | 82 + tests/test_interactions.py | 99 + tests/test_mcp.py | 165 + tests/test_mcp_connectors.py | 277 + tests/test_mcp_oauth.py | 200 + tests/test_memory.py | 362 + tests/test_memory_pipeline.py | 247 + tests/test_mention_router.py | 290 + tests/test_message_source.py | 262 + tests/test_message_timestamps.py | 152 + tests/test_minem_connector.py | 146 + tests/test_model_errors.py | 86 + tests/test_model_purposes.py | 82 + tests/test_multiroot.py | 322 + tests/test_namespace_compat.py | 18 + tests/test_pdf_support.py | 120 + tests/test_permissions_risk.py | 151 + tests/test_persona_connections.py | 249 + tests/test_persona_loading.py | 134 + tests/test_persona_manifest.py | 167 + tests/test_persona_registry.py | 102 + tests/test_plan_mode.py | 250 + tests/test_project_sessions.py | 61 + tests/test_provider_router.py | 565 ++ tests/test_provider_verify.py | 104 + tests/test_providers.py | 456 ++ tests/test_risk_overrides.py | 68 + tests/test_secrets.py | 118 + tests/test_self_wake.py | 66 + tests/test_send_file.py | 157 + tests/test_send_target_resolution.py | 202 + tests/test_sender_attribution.py | 117 + tests/test_sensory.py | 227 + tests/test_server.py | 1133 +++ tests/test_server_startup.py | 25 + tests/test_session_events.py | 135 + tests/test_session_persona.py | 74 + tests/test_settings.py | 176 + tests/test_shell.py | 183 + tests/test_skills.py | 284 + tests/test_slack_approval_owners.py | 343 + tests/test_slack_directory.py | 142 + tests/test_slack_relay.py | 424 + tests/test_slack_status.py | 227 + tests/test_slack_workspaces.py | 134 + tests/test_standing_approvals.py | 525 ++ tests/test_subagent.py | 169 + tests/test_subscriptions.py | 330 + tests/test_task_runtime.py | 292 + tests/test_team_allowlist.py | 264 + tests/test_todo_tool.py | 34 + tests/test_tools_permissions.py | 151 + tests/test_trae_provider.py | 63 + tests/test_traex_connector.py | 225 + tests/test_tui.py | 77 + tests/test_ui_refresh_e2e.py | 309 + tests/test_unattended.py | 48 + tests/test_wake_resume.py | 62 + tests/test_web_search.py | 154 + tsconfig.json | 27 - ui-mocks/connectors-redesign.html | 636 ++ ui-mocks/redesign.html | 919 +++ ui-mocks/voice-input-composer-states.html | 132 + ui-mocks/voice-input-settings.html | 142 + vite.config.ts | 44 - 627 files changed, 122943 insertions(+), 25330 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .env.example create mode 100644 .github/workflows/release.yml delete mode 100644 .prettierignore delete mode 100644 .prettierrc delete mode 100644 CHANGELOG.md create mode 100644 CHANGES.md delete mode 100644 Dockerfile create mode 100644 PROJECT_LAYOUT.md delete mode 100644 README.en.md create mode 100644 THIRD_PARTY_LICENSES/OpenWorker-MIT.txt create mode 100644 THIRD_PARTY_NOTICES.md delete mode 100644 agent/link-agent.mjs delete mode 100644 components.json delete mode 100644 crawler_backend/app.py delete mode 100644 db/init/001_schema.sql delete mode 100755 deploy/aliyun/bootstrap.sh delete mode 100755 deploy/aliyun/deploy-existing-ecs.sh delete mode 100755 deploy/aliyun/open-tunnel.sh create mode 100644 design/brand/README.md create mode 100644 design/brand/smallink-mark.png create mode 100644 design/brand/smallink-wordmark-source.png delete mode 100644 docker-compose.yml delete mode 100644 docs/aliyun-private-deployment.md create mode 100644 docs/assets/how-it-works.png delete mode 100644 docs/assets/link-data-overview.png create mode 100644 docs/config.example.toml create mode 100644 docs/current-memory-product-logic-and-structure.md delete mode 100644 docs/data-intake-prd.md delete mode 100644 docs/data-intake-technical-design.md delete mode 100644 docs/link-agent-client-prd.md delete mode 100644 docs/link-agent-prd.md delete mode 100644 docs/link-agent-technical-design.md create mode 100644 docs/link-memory-prd.md create mode 100644 docs/link-memory-technical-design.md create mode 100644 docs/link-product-prd.md create mode 100644 docs/link-technical-design.md delete mode 100644 docs/local-stable-run.md create mode 100644 docs/minem-smallink-integration-blueprint.md create mode 100644 docs/project-module-prd.md create mode 100644 docs/smallink-agent-ai-foundation-guide.md create mode 100644 docs/smallink-app-center-minem-prd.md create mode 100644 docs/smallink-app-center-minem-technical-design.md create mode 100644 docs/smallink-app-center-minem-test-plan.md create mode 100644 docs/smallink-architecture-guide.md create mode 100644 docs/smallink-minem-connector-design.md create mode 100644 docs/smallink-next-version-prd.md create mode 100644 docs/smallink-ui-design-system.md delete mode 100644 eslint.config.js delete mode 100644 link-agent-client/.gitignore delete mode 100644 link-agent-client/.vscode/extensions.json delete mode 100644 link-agent-client/README.md delete mode 100644 link-agent-client/package-lock.json delete mode 100644 link-agent-client/package.json delete mode 100644 link-agent-client/public/tauri.svg delete mode 100644 link-agent-client/public/vite.svg delete mode 100644 link-agent-client/src-tauri/.gitignore delete mode 100644 link-agent-client/src-tauri/Cargo.toml delete mode 100644 link-agent-client/src-tauri/build.rs delete mode 100644 link-agent-client/src-tauri/capabilities/default.json delete mode 100644 link-agent-client/src-tauri/icons/128x128.png delete mode 100644 link-agent-client/src-tauri/icons/128x128@2x.png delete mode 100644 link-agent-client/src-tauri/icons/32x32.png delete mode 100644 link-agent-client/src-tauri/icons/Square107x107Logo.png delete mode 100644 link-agent-client/src-tauri/icons/Square142x142Logo.png delete mode 100644 link-agent-client/src-tauri/icons/Square150x150Logo.png delete mode 100644 link-agent-client/src-tauri/icons/Square284x284Logo.png delete mode 100644 link-agent-client/src-tauri/icons/Square30x30Logo.png delete mode 100644 link-agent-client/src-tauri/icons/Square310x310Logo.png delete mode 100644 link-agent-client/src-tauri/icons/Square44x44Logo.png delete mode 100644 link-agent-client/src-tauri/icons/Square71x71Logo.png delete mode 100644 link-agent-client/src-tauri/icons/Square89x89Logo.png delete mode 100644 link-agent-client/src-tauri/icons/StoreLogo.png delete mode 100644 link-agent-client/src-tauri/icons/icon.icns delete mode 100644 link-agent-client/src-tauri/icons/icon.ico delete mode 100644 link-agent-client/src-tauri/icons/icon.png delete mode 100644 link-agent-client/src-tauri/src/lib.rs delete mode 100644 link-agent-client/src-tauri/src/main.rs delete mode 100644 link-agent-client/src-tauri/tauri.conf.json delete mode 100644 link-agent-client/src/App.css delete mode 100644 link-agent-client/src/App.tsx delete mode 100644 link-agent-client/src/assets/react.svg delete mode 100644 link-agent-client/src/main.tsx delete mode 100644 link-agent-client/tsconfig.node.json delete mode 100644 link-agent-client/vite.config.ts create mode 100644 link/__init__.py delete mode 100644 package-lock.json delete mode 100644 package.json create mode 100644 packaging/.gitignore create mode 100755 packaging/build_dmg.sh create mode 100644 packaging/dmg-background.tiff create mode 100644 packaging/entry_smallink_server.py create mode 100644 packaging/smallink-server.spec delete mode 100644 public/downloads/.gitkeep create mode 100644 pyproject.toml create mode 100644 scripts/check_versions.py create mode 100755 scripts/connect_codex.sh delete mode 100644 scripts/import-codex-history.mjs create mode 100644 smallink/__init__.py create mode 100644 smallink/agent.py create mode 100644 smallink/agents/__init__.py create mode 100644 smallink/agents/base.py create mode 100644 smallink/agents/chat.py create mode 100644 smallink/agents/code.py create mode 100644 smallink/agents/link.py create mode 100644 smallink/agents/registry.py create mode 100644 smallink/attachments.py create mode 100644 smallink/audit.py create mode 100644 smallink/automation/__init__.py create mode 100644 smallink/automation/models.py create mode 100644 smallink/automation/scheduler.py create mode 100644 smallink/automation/store.py create mode 100644 smallink/automation/tools.py create mode 100644 smallink/catalog.py create mode 100644 smallink/cli.py create mode 100644 smallink/cloud.py create mode 100644 smallink/config.py create mode 100644 smallink/connections.py create mode 100644 smallink/connectors/__init__.py create mode 100644 smallink/connectors/accounts.py create mode 100644 smallink/connectors/adapters.py create mode 100644 smallink/connectors/attribution.py create mode 100644 smallink/connectors/base.py create mode 100644 smallink/connectors/browser_automation.py create mode 100644 smallink/connectors/catalog_copy.py create mode 100644 smallink/connectors/cli.py create mode 100644 smallink/connectors/codex_client.py create mode 100644 smallink/connectors/config.py create mode 100644 smallink/connectors/descriptors.py create mode 100644 smallink/connectors/email_tools.py create mode 100644 smallink/connectors/experimental/__init__.py create mode 100644 smallink/connectors/fake.py create mode 100644 smallink/connectors/gateway.py create mode 100644 smallink/connectors/gcal_accounts.py create mode 100644 smallink/connectors/github_installs.py create mode 100644 smallink/connectors/github_relay.py create mode 100644 smallink/connectors/gmail_accounts.py create mode 100644 smallink/connectors/hubspot_portals.py create mode 100644 smallink/connectors/integration_tools.py create mode 100644 smallink/connectors/minem_client.py create mode 100644 smallink/connectors/parked.py create mode 100644 smallink/connectors/relay_client.py create mode 100644 smallink/connectors/senders.py create mode 100644 smallink/connectors/setup.py create mode 100644 smallink/connectors/slack_addr.py create mode 100644 smallink/connectors/slack_directory.py create mode 100644 smallink/connectors/sync_store.py create mode 100644 smallink/connectors/tool_defs.py create mode 100644 smallink/connectors/tools.py create mode 100644 smallink/connectors/traex_client.py create mode 100644 smallink/conversations.py create mode 100644 smallink/engine.py create mode 100644 smallink/environment.py create mode 100644 smallink/events.py create mode 100644 smallink/inbox.py create mode 100644 smallink/inbox_routing.py create mode 100644 smallink/interactions.py create mode 100644 smallink/mcp/__init__.py create mode 100644 smallink/mcp/client.py create mode 100644 smallink/mcp/config.py create mode 100644 smallink/mcp/oauth.py create mode 100644 smallink/mcp/tools.py create mode 100644 smallink/memory/__init__.py create mode 100644 smallink/memory/base.py create mode 100644 smallink/memory/governance.py create mode 100644 smallink/memory/pipeline.py create mode 100644 smallink/memory/retrieval.py create mode 100644 smallink/memory/sqlite_store.py create mode 100644 smallink/memory/tools.py create mode 100644 smallink/mentions.py create mode 100644 smallink/overrides.py create mode 100644 smallink/pdf_support.py create mode 100644 smallink/permissions.py create mode 100644 smallink/personas/__init__.py create mode 100644 smallink/personas/builtin/ops.md create mode 100644 smallink/personas/loading.py create mode 100644 smallink/personas/manifest.py create mode 100644 smallink/personas/registry.py create mode 100644 smallink/project.py create mode 100644 smallink/projects.py create mode 100644 smallink/providers/__init__.py create mode 100644 smallink/providers/anthropic_provider.py create mode 100644 smallink/providers/base.py create mode 100644 smallink/providers/capabilities.py create mode 100644 smallink/providers/errors.py create mode 100644 smallink/providers/gemini_provider.py create mode 100644 smallink/providers/matrix.py create mode 100644 smallink/providers/openai_provider.py create mode 100644 smallink/providers/registry.py create mode 100644 smallink/providers/router.py create mode 100644 smallink/providers/trae_provider.py create mode 100644 smallink/risk.py create mode 100644 smallink/roots.py create mode 100644 smallink/secrets.py create mode 100644 smallink/selfwake.py create mode 100644 smallink/sensory/__init__.py create mode 100644 smallink/sensory/models.py create mode 100644 smallink/sensory/store.py create mode 100644 smallink/server/__init__.py create mode 100644 smallink/server/app.py create mode 100644 smallink/server/manager.py create mode 100644 smallink/server/routers/__init__.py create mode 100644 smallink/server/routers/memory.py create mode 100644 smallink/server/routers/prompts.py create mode 100644 smallink/server/run.py create mode 100644 smallink/sessions.py create mode 100644 smallink/skills/__init__.py create mode 100644 smallink/skills/base.py create mode 100644 smallink/skills/store.py create mode 100644 smallink/sqlite.py create mode 100644 smallink/subscriptions.py create mode 100644 smallink/task/__init__.py create mode 100644 smallink/task/models.py create mode 100644 smallink/task/observer.py create mode 100644 smallink/task/store.py create mode 100644 smallink/testing/__init__.py create mode 100644 smallink/testing/fake_slack/__init__.py create mode 100644 smallink/testing/fake_slack/__main__.py create mode 100644 smallink/testing/fake_slack/server.py create mode 100644 smallink/tools/__init__.py create mode 100644 smallink/tools/ask.py create mode 100644 smallink/tools/directories.py create mode 100644 smallink/tools/files.py create mode 100644 smallink/tools/git.py create mode 100644 smallink/tools/plan.py create mode 100644 smallink/tools/registry.py create mode 100644 smallink/tools/search.py create mode 100644 smallink/tools/shell.py create mode 100644 smallink/tools/subagent.py create mode 100644 smallink/tools/todo.py create mode 100644 smallink/tui/__init__.py create mode 100644 smallink/tui/app.py create mode 100644 smallink/unattended.py create mode 100644 smallink/unrouted.py create mode 100644 smallink/web/__init__.py create mode 100644 smallink/web/fetch.py create mode 100644 smallink/web/providers.py create mode 100644 smallink/web/tool.py create mode 100644 smallink/workspace_trust.py delete mode 100644 src/assets/smallink-logo.webp delete mode 100644 src/components/top-nav.tsx delete mode 100644 src/components/ui/accordion.tsx delete mode 100644 src/components/ui/alert-dialog.tsx delete mode 100644 src/components/ui/alert.tsx delete mode 100644 src/components/ui/aspect-ratio.tsx delete mode 100644 src/components/ui/avatar.tsx delete mode 100644 src/components/ui/badge.tsx delete mode 100644 src/components/ui/breadcrumb.tsx delete mode 100644 src/components/ui/button.tsx delete mode 100644 src/components/ui/calendar.tsx delete mode 100644 src/components/ui/card.tsx delete mode 100644 src/components/ui/carousel.tsx delete mode 100644 src/components/ui/chart.tsx delete mode 100644 src/components/ui/checkbox.tsx delete mode 100644 src/components/ui/collapsible.tsx delete mode 100644 src/components/ui/command.tsx delete mode 100644 src/components/ui/context-menu.tsx delete mode 100644 src/components/ui/dialog.tsx delete mode 100644 src/components/ui/drawer.tsx delete mode 100644 src/components/ui/dropdown-menu.tsx delete mode 100644 src/components/ui/form.tsx delete mode 100644 src/components/ui/hover-card.tsx delete mode 100644 src/components/ui/input-otp.tsx delete mode 100644 src/components/ui/input.tsx delete mode 100644 src/components/ui/label.tsx delete mode 100644 src/components/ui/menubar.tsx delete mode 100644 src/components/ui/navigation-menu.tsx delete mode 100644 src/components/ui/pagination.tsx delete mode 100644 src/components/ui/popover.tsx delete mode 100644 src/components/ui/progress.tsx delete mode 100644 src/components/ui/radio-group.tsx delete mode 100644 src/components/ui/resizable.tsx delete mode 100644 src/components/ui/scroll-area.tsx delete mode 100644 src/components/ui/select.tsx delete mode 100644 src/components/ui/separator.tsx delete mode 100644 src/components/ui/sheet.tsx delete mode 100644 src/components/ui/sidebar.tsx delete mode 100644 src/components/ui/skeleton.tsx delete mode 100644 src/components/ui/slider.tsx delete mode 100644 src/components/ui/sonner.tsx delete mode 100644 src/components/ui/switch.tsx delete mode 100644 src/components/ui/table.tsx delete mode 100644 src/components/ui/tabs.tsx delete mode 100644 src/components/ui/textarea.tsx delete mode 100644 src/components/ui/toggle-group.tsx delete mode 100644 src/components/ui/toggle.tsx delete mode 100644 src/components/ui/tooltip.tsx delete mode 100644 src/hooks/use-mobile.tsx delete mode 100644 src/lib/error-capture.ts delete mode 100644 src/lib/error-page.ts delete mode 100644 src/lib/error-reporting.ts delete mode 100644 src/lib/server-db.ts delete mode 100644 src/lib/utils.ts delete mode 100644 src/routeTree.gen.ts delete mode 100644 src/router.tsx delete mode 100644 src/routes/README.md delete mode 100644 src/routes/__root.tsx delete mode 100644 src/routes/codex.tsx delete mode 100644 src/routes/crawler.tsx delete mode 100644 src/routes/data.tsx delete mode 100644 src/routes/index.tsx delete mode 100644 src/routes/settings.tsx delete mode 100644 src/server.ts delete mode 100644 src/start.ts delete mode 100644 src/styles.css create mode 100644 stt/.gitignore create mode 100644 stt/Cargo.lock create mode 100644 stt/Cargo.toml create mode 100644 stt/src/lib.rs create mode 100644 surfaces/gui/.gitignore create mode 100644 surfaces/gui/README.md create mode 100644 surfaces/gui/assets/icon.png create mode 100644 surfaces/gui/e2e/README.md create mode 100644 surfaces/gui/e2e/accounts-page.spec.ts create mode 100644 surfaces/gui/e2e/cloud-status-pending.spec.ts create mode 100644 surfaces/gui/e2e/fixtures.ts create mode 100644 surfaces/gui/e2e/gallery.spec.ts create mode 100644 surfaces/gui/e2e/project-session-isolation.spec.ts rename {link-agent-client => surfaces/gui}/index.html (51%) create mode 100644 surfaces/gui/package-lock.json create mode 100644 surfaces/gui/package.json create mode 100644 surfaces/gui/playwright.config.ts create mode 100644 surfaces/gui/playwright.live.config.ts create mode 100644 surfaces/gui/postcss.config.js create mode 100644 surfaces/gui/src-tauri/.gitignore rename {link-agent-client => surfaces/gui}/src-tauri/Cargo.lock (89%) create mode 100644 surfaces/gui/src-tauri/Cargo.toml create mode 100644 surfaces/gui/src-tauri/build.rs create mode 100644 surfaces/gui/src-tauri/entitlements.plist create mode 100644 surfaces/gui/src-tauri/icons/128x128.png create mode 100644 surfaces/gui/src-tauri/icons/128x128@2x.png create mode 100644 surfaces/gui/src-tauri/icons/32x32.png create mode 100644 surfaces/gui/src-tauri/icons/64x64.png create mode 100644 surfaces/gui/src-tauri/icons/Square107x107Logo.png create mode 100644 surfaces/gui/src-tauri/icons/Square142x142Logo.png create mode 100644 surfaces/gui/src-tauri/icons/Square150x150Logo.png create mode 100644 surfaces/gui/src-tauri/icons/Square284x284Logo.png create mode 100644 surfaces/gui/src-tauri/icons/Square30x30Logo.png create mode 100644 surfaces/gui/src-tauri/icons/Square310x310Logo.png create mode 100644 surfaces/gui/src-tauri/icons/Square44x44Logo.png create mode 100644 surfaces/gui/src-tauri/icons/Square71x71Logo.png create mode 100644 surfaces/gui/src-tauri/icons/Square89x89Logo.png create mode 100644 surfaces/gui/src-tauri/icons/StoreLogo.png create mode 100644 surfaces/gui/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 surfaces/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png create mode 100644 surfaces/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 surfaces/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png create mode 100644 surfaces/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png create mode 100644 surfaces/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 surfaces/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png create mode 100644 surfaces/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png create mode 100644 surfaces/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 surfaces/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png create mode 100644 surfaces/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png create mode 100644 surfaces/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 surfaces/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 surfaces/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png create mode 100644 surfaces/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 surfaces/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 surfaces/gui/src-tauri/icons/android/values/ic_launcher_background.xml create mode 100644 surfaces/gui/src-tauri/icons/icon.icns create mode 100644 surfaces/gui/src-tauri/icons/icon.ico create mode 100644 surfaces/gui/src-tauri/icons/icon.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-20x20@1x.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-20x20@2x-1.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-20x20@2x.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-20x20@3x.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-29x29@1x.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-29x29@2x-1.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-29x29@2x.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-29x29@3x.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-40x40@1x.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-40x40@2x-1.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-40x40@2x.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-40x40@3x.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-512@2x.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-60x60@2x.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-60x60@3x.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-76x76@1x.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-76x76@2x.png create mode 100644 surfaces/gui/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png create mode 100644 surfaces/gui/src-tauri/src/lib.rs create mode 100644 surfaces/gui/src-tauri/src/main.rs create mode 100644 surfaces/gui/src-tauri/tauri.conf.json create mode 100644 surfaces/gui/src/App.tsx create mode 100644 surfaces/gui/src/api.auth.test.ts create mode 100644 surfaces/gui/src/api.ts create mode 100644 surfaces/gui/src/assets/smallink-mark.png create mode 100644 surfaces/gui/src/attach.ts create mode 100644 surfaces/gui/src/components/AccessSection.tsx create mode 100644 surfaces/gui/src/components/AddFolderForm.tsx create mode 100644 surfaces/gui/src/components/AgentsView.test.tsx create mode 100644 surfaces/gui/src/components/AgentsView.tsx create mode 100644 surfaces/gui/src/components/AppErrorBoundary.tsx create mode 100644 surfaces/gui/src/components/ApprovalCard.test.tsx create mode 100644 surfaces/gui/src/components/ApprovalCard.tsx create mode 100644 surfaces/gui/src/components/AsyncFeedback.tsx create mode 100644 surfaces/gui/src/components/AuditView.tsx create mode 100644 surfaces/gui/src/components/AutomationQuickstart.tsx create mode 100644 surfaces/gui/src/components/CompactSidebar.test.tsx create mode 100644 surfaces/gui/src/components/CompactSidebar.tsx create mode 100644 surfaces/gui/src/components/Composer.tsx create mode 100644 surfaces/gui/src/components/Composer.voice.test.tsx create mode 100644 surfaces/gui/src/components/ConfirmDialog.tsx create mode 100644 surfaces/gui/src/components/ConnectorMessageCard.test.tsx create mode 100644 surfaces/gui/src/components/ConnectorMessageCard.tsx create mode 100644 surfaces/gui/src/components/CreateProjectModal.tsx create mode 100644 surfaces/gui/src/components/DirectoryRequestCard.tsx create mode 100644 surfaces/gui/src/components/Dropdown.tsx create mode 100644 surfaces/gui/src/components/FolderGate.tsx create mode 100644 surfaces/gui/src/components/GalleryModal.tsx create mode 100644 surfaces/gui/src/components/Icon.tsx create mode 100644 surfaces/gui/src/components/InboxConfigure.tsx create mode 100644 surfaces/gui/src/components/InboxItemCard.tsx create mode 100644 surfaces/gui/src/components/InboxView.tsx create mode 100644 surfaces/gui/src/components/IntegrationsView.test.tsx create mode 100644 surfaces/gui/src/components/IntegrationsView.tsx create mode 100644 surfaces/gui/src/components/ManageTabs.tsx create mode 100644 surfaces/gui/src/components/Markdown.test.tsx create mode 100644 surfaces/gui/src/components/Markdown.tsx create mode 100644 surfaces/gui/src/components/MemoryView.test.tsx create mode 100644 surfaces/gui/src/components/MemoryView.tsx create mode 100644 surfaces/gui/src/components/ModelChecklist.test.tsx create mode 100644 surfaces/gui/src/components/ModelChecklist.tsx create mode 100644 surfaces/gui/src/components/Onboarding.tsx create mode 100644 surfaces/gui/src/components/PersonaHero.tsx create mode 100644 surfaces/gui/src/components/PersonaView.test.tsx create mode 100644 surfaces/gui/src/components/PersonaView.tsx create mode 100644 surfaces/gui/src/components/PersonasTab.tsx create mode 100644 surfaces/gui/src/components/PlanCard.tsx create mode 100644 surfaces/gui/src/components/PromptsView.tsx create mode 100644 surfaces/gui/src/components/RightRail.test.tsx create mode 100644 surfaces/gui/src/components/RightRail.tsx create mode 100644 surfaces/gui/src/components/RootRow.tsx create mode 100644 surfaces/gui/src/components/RunsView.test.tsx create mode 100644 surfaces/gui/src/components/RunsView.tsx create mode 100644 surfaces/gui/src/components/ScheduledView.tsx create mode 100644 surfaces/gui/src/components/SearchModal.tsx create mode 100644 surfaces/gui/src/components/SelectMenu.tsx create mode 100644 surfaces/gui/src/components/SessionIntro.tsx create mode 100644 surfaces/gui/src/components/SettingsView.tsx create mode 100644 surfaces/gui/src/components/Sidebar.test.tsx create mode 100644 surfaces/gui/src/components/Sidebar.tsx create mode 100644 surfaces/gui/src/components/SkillHub.test.tsx create mode 100644 surfaces/gui/src/components/SkillHub.tsx create mode 100644 surfaces/gui/src/components/SubscriptionsChip.tsx create mode 100644 surfaces/gui/src/components/TodoPanel.tsx create mode 100644 surfaces/gui/src/components/Toggle.tsx create mode 100644 surfaces/gui/src/components/Transcript.test.tsx create mode 100644 surfaces/gui/src/components/Transcript.tsx create mode 100644 surfaces/gui/src/components/WorkspaceTrustPrompt.tsx create mode 100644 surfaces/gui/src/components/brandIcons.tsx create mode 100644 surfaces/gui/src/components/connectors/AccountsDetail.tsx create mode 100644 surfaces/gui/src/components/connectors/AddConnectionModal.test.tsx create mode 100644 surfaces/gui/src/components/connectors/AddConnectionModal.tsx create mode 100644 surfaces/gui/src/components/connectors/AvailableDetail.tsx create mode 100644 surfaces/gui/src/components/connectors/CalendarDetail.tsx create mode 100644 surfaces/gui/src/components/connectors/CloudSignIn.tsx create mode 100644 surfaces/gui/src/components/connectors/ConnectorsList.tsx create mode 100644 surfaces/gui/src/components/connectors/ConnectorsSection.test.tsx create mode 100644 surfaces/gui/src/components/connectors/ConnectorsSection.tsx create mode 100644 surfaces/gui/src/components/connectors/GithubDetail.tsx create mode 100644 surfaces/gui/src/components/connectors/GmailDetail.tsx create mode 100644 surfaces/gui/src/components/connectors/HubSpotDetail.tsx create mode 100644 surfaces/gui/src/components/connectors/SlackDetail.tsx create mode 100644 surfaces/gui/src/components/connectors/SlackHowItWorks.tsx create mode 100644 surfaces/gui/src/components/connectors/ToolsDisclosure.tsx create mode 100644 surfaces/gui/src/components/connectors/ui.ts create mode 100644 surfaces/gui/src/components/connectors/visibility.ts create mode 100644 surfaces/gui/src/components/personaIcon.tsx create mode 100644 surfaces/gui/src/connectors/ConnectorIcon.test.tsx create mode 100644 surfaces/gui/src/connectors/ConnectorIcon.tsx create mode 100644 surfaces/gui/src/connectors/registry.tsx create mode 100644 surfaces/gui/src/connectors/visuals.ts create mode 100644 surfaces/gui/src/flags.ts create mode 100644 surfaces/gui/src/fonts/manrope-700.woff2 create mode 100644 surfaces/gui/src/humanize.ts create mode 100644 surfaces/gui/src/i18n.test.tsx create mode 100644 surfaces/gui/src/i18n.tsx create mode 100644 surfaces/gui/src/itemsFromMessages.test.ts create mode 100644 surfaces/gui/src/itemsFromMessages.ts create mode 100644 surfaces/gui/src/localPickerApi.ts create mode 100644 surfaces/gui/src/main.tsx create mode 100644 surfaces/gui/src/paths.ts create mode 100644 surfaces/gui/src/personaScope.ts create mode 100644 surfaces/gui/src/providers/ProviderSetup.tsx create mode 100644 surfaces/gui/src/providers/logos.ts create mode 100644 surfaces/gui/src/providers/logos/anthropic.svg create mode 100644 surfaces/gui/src/providers/logos/deepseek.svg create mode 100644 surfaces/gui/src/providers/logos/fireworks.svg create mode 100644 surfaces/gui/src/providers/logos/gemini.svg create mode 100644 surfaces/gui/src/providers/logos/kimi.svg create mode 100644 surfaces/gui/src/providers/logos/meta.svg create mode 100644 surfaces/gui/src/providers/logos/minimax.svg create mode 100644 surfaces/gui/src/providers/logos/mistral.svg create mode 100644 surfaces/gui/src/providers/logos/ollama.svg create mode 100644 surfaces/gui/src/providers/logos/openai.svg create mode 100644 surfaces/gui/src/providers/logos/qwen.svg create mode 100644 surfaces/gui/src/providers/logos/together.svg create mode 100644 surfaces/gui/src/providers/logos/xai.svg create mode 100644 surfaces/gui/src/providers/logos/zai.svg create mode 100644 surfaces/gui/src/streamGate.test.ts create mode 100644 surfaces/gui/src/streamGate.ts create mode 100644 surfaces/gui/src/styles.css create mode 100644 surfaces/gui/src/tailwind.css create mode 100644 surfaces/gui/src/tauri.test.ts create mode 100644 surfaces/gui/src/tauri.ts create mode 100644 surfaces/gui/src/theme.ts create mode 100644 surfaces/gui/src/types.ts create mode 100644 surfaces/gui/src/useRoots.ts rename {link-agent-client => surfaces/gui}/src/vite-env.d.ts (100%) create mode 100644 surfaces/gui/src/windowDrag.test.ts create mode 100644 surfaces/gui/src/windowDrag.ts create mode 100644 surfaces/gui/tailwind.config.js rename {link-agent-client => surfaces/gui}/tsconfig.json (92%) create mode 100644 surfaces/gui/tsconfig.node.json create mode 100644 surfaces/gui/vite.config.ts create mode 100644 surfaces/gui/vitest.config.ts create mode 100644 tests/conftest.py create mode 100644 tests/test_accounts.py create mode 100644 tests/test_anthropic_provider.py create mode 100644 tests/test_attachments.py create mode 100644 tests/test_automation.py create mode 100644 tests/test_automation_create.py create mode 100644 tests/test_autotitle.py create mode 100644 tests/test_builtin_personas.py create mode 100644 tests/test_catalog.py create mode 100644 tests/test_cloud.py create mode 100644 tests/test_cloud_server.py create mode 100644 tests/test_code_tools.py create mode 100644 tests/test_codex_connector.py create mode 100644 tests/test_config.py create mode 100644 tests/test_connections.py create mode 100644 tests/test_connector_registry.py create mode 100644 tests/test_connectors.py create mode 100644 tests/test_connectors_allowlist.py create mode 100644 tests/test_dm_routing.py create mode 100644 tests/test_durable_resume.py create mode 100644 tests/test_email_tools.py create mode 100644 tests/test_engine.py create mode 100644 tests/test_engine_stop.py create mode 100644 tests/test_environment.py create mode 100644 tests/test_fake_slack.py create mode 100644 tests/test_gateway_inbox_reply.py create mode 100644 tests/test_gcal_accounts.py create mode 100644 tests/test_gemini_provider.py create mode 100644 tests/test_github_installs.py create mode 100644 tests/test_gmail_accounts.py create mode 100644 tests/test_hubspot_portals.py create mode 100644 tests/test_inbox.py create mode 100644 tests/test_inbox_routing.py create mode 100644 tests/test_interactions.py create mode 100644 tests/test_mcp.py create mode 100644 tests/test_mcp_connectors.py create mode 100644 tests/test_mcp_oauth.py create mode 100644 tests/test_memory.py create mode 100644 tests/test_memory_pipeline.py create mode 100644 tests/test_mention_router.py create mode 100644 tests/test_message_source.py create mode 100644 tests/test_message_timestamps.py create mode 100644 tests/test_minem_connector.py create mode 100644 tests/test_model_errors.py create mode 100644 tests/test_model_purposes.py create mode 100644 tests/test_multiroot.py create mode 100644 tests/test_namespace_compat.py create mode 100644 tests/test_pdf_support.py create mode 100644 tests/test_permissions_risk.py create mode 100644 tests/test_persona_connections.py create mode 100644 tests/test_persona_loading.py create mode 100644 tests/test_persona_manifest.py create mode 100644 tests/test_persona_registry.py create mode 100644 tests/test_plan_mode.py create mode 100644 tests/test_project_sessions.py create mode 100644 tests/test_provider_router.py create mode 100644 tests/test_provider_verify.py create mode 100644 tests/test_providers.py create mode 100644 tests/test_risk_overrides.py create mode 100644 tests/test_secrets.py create mode 100644 tests/test_self_wake.py create mode 100644 tests/test_send_file.py create mode 100644 tests/test_send_target_resolution.py create mode 100644 tests/test_sender_attribution.py create mode 100644 tests/test_sensory.py create mode 100644 tests/test_server.py create mode 100644 tests/test_server_startup.py create mode 100644 tests/test_session_events.py create mode 100644 tests/test_session_persona.py create mode 100644 tests/test_settings.py create mode 100644 tests/test_shell.py create mode 100644 tests/test_skills.py create mode 100644 tests/test_slack_approval_owners.py create mode 100644 tests/test_slack_directory.py create mode 100644 tests/test_slack_relay.py create mode 100644 tests/test_slack_status.py create mode 100644 tests/test_slack_workspaces.py create mode 100644 tests/test_standing_approvals.py create mode 100644 tests/test_subagent.py create mode 100644 tests/test_subscriptions.py create mode 100644 tests/test_task_runtime.py create mode 100644 tests/test_team_allowlist.py create mode 100644 tests/test_todo_tool.py create mode 100644 tests/test_tools_permissions.py create mode 100644 tests/test_trae_provider.py create mode 100644 tests/test_traex_connector.py create mode 100644 tests/test_tui.py create mode 100644 tests/test_ui_refresh_e2e.py create mode 100644 tests/test_unattended.py create mode 100644 tests/test_wake_resume.py create mode 100644 tests/test_web_search.py delete mode 100644 tsconfig.json create mode 100644 ui-mocks/connectors-redesign.html create mode 100644 ui-mocks/redesign.html create mode 100644 ui-mocks/voice-input-composer-states.html create mode 100644 ui-mocks/voice-input-settings.html delete mode 100644 vite.config.ts diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index debb769..0000000 --- a/.dockerignore +++ /dev/null @@ -1,18 +0,0 @@ -node_modules -.output -backups -*.dump -.env -.env.* -link-agent-client/node_modules -link-agent-client/dist -link-agent-client/src-tauri/target -.git -.DS_Store -npm-debug.log -src/lib/codex-history.generated.json -src/lib/codex-history.generated.ts -public/downloads/*.dmg -public/downloads/*.zip -Dockerfile -docker-compose.yml diff --git a/.env.example b/.env.example deleted file mode 100644 index d48b93c..0000000 --- a/.env.example +++ /dev/null @@ -1,10 +0,0 @@ -LINK_PORT=41737 -POSTGRES_PORT=55437 -CRAWLER_PORT=18744 -LINK_DEPLOYMENT_MODE=local -POSTGRES_USER=link -POSTGRES_PASSWORD=link_local_dev_only -POSTGRES_DB=link -DATABASE_URL=postgres://link:link_local_dev_only@127.0.0.1:55437/link -OPENAI_API_KEY= -CODEX_AI_API_KEY= diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 7b705a1..9a0fb14 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report a reproducible problem in Link or LinkAgent +description: Report a reproducible problem in Smallink title: "[Bug]: " labels: - bug @@ -7,17 +7,18 @@ body: - type: markdown attributes: value: | - Thanks for helping improve Link. Remove tokens, private paths, and raw conversation content before attaching logs or screenshots. + Thanks for helping improve Smallink. Remove tokens, private paths, and raw conversation content before attaching logs or screenshots. - type: dropdown id: component attributes: label: Component options: - - Link Web / platform API - - LinkAgent - - Codex connector - - Web crawler - - Docker / deployment + - macOS desktop client + - Local sidecar / API + - Agent runtime and tools + - Memory and data governance + - MineM application / connector + - Automation and run center - Documentation validations: required: true @@ -25,7 +26,7 @@ body: id: version attributes: label: Version - placeholder: 0.2.1 Developer Preview or commit SHA + placeholder: App version or commit SHA validations: required: true - type: textarea @@ -55,7 +56,7 @@ body: id: environment attributes: label: Environment - placeholder: macOS version, CPU architecture, Docker version, browser + placeholder: macOS version, CPU architecture, installation type validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index cc37837..8d4639a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,5 +1,5 @@ name: Feature request -description: Propose a connector or product capability grounded in a real workflow +description: Propose a Smallink capability grounded in a real workflow title: "[Feature]: " labels: - enhancement @@ -7,7 +7,7 @@ body: - type: markdown attributes: value: | - Link is building the data intake foundation first. A strong proposal explains the user problem, source boundary, and observable result before suggesting implementation details. + A strong proposal explains the user problem, capability boundary, source data, permissions, and observable result before suggesting implementation details. - type: textarea id: problem attributes: @@ -27,19 +27,20 @@ body: attributes: label: Product area options: - - New connector - - LinkAgent desktop experience - - Raw data browsing - - Web crawler - - Deployment and operations + - Agent and multi-agent collaboration + - Memory and data governance + - New application or connector + - Desktop interaction + - Automation and run center + - Model provider and tools - Developer experience validations: required: true - type: textarea id: source attributes: - label: Source and authorization boundary - description: For connector ideas, explain where the data lives and what the user should authorize. + label: Data source and authorization boundary + description: Explain where the data lives, what Smallink should access, and what the user should authorize. - type: textarea id: evidence attributes: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ffeca0e..af2a226 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,10 +8,11 @@ ## 验证 -- [ ] `npm run lint` -- [ ] `npm run build` -- [ ] LinkAgent 前端构建 -- [ ] LinkAgent Rust 测试 +- [ ] `pytest tests -q` +- [ ] `npm --prefix surfaces/gui test` +- [ ] `npm --prefix surfaces/gui run build` +- [ ] `npm --prefix surfaces/gui run e2e` +- [ ] `cargo check --manifest-path surfaces/gui/src-tauri/Cargo.toml` ## 数据与隐私 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ff484b..5ceb3d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,39 +1,61 @@ -name: CI +# Smallink CI — the Python suite, GUI unit tests, and the hermetic +# Playwright e2e suite (mocked /v1 + WS; no model or network needed). -on: - push: - branches: [main] - pull_request: +name: CI -permissions: - contents: read +on: [push, pull_request] jobs: - platform: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e ".[messaging,dev]" + - name: Test + run: pytest tests -q + - name: Version consistency + run: python scripts/check_versions.py + + gui-unit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 24 + node-version: 20 cache: npm - - run: npm ci - - run: npm run lint - - run: npm run build + cache-dependency-path: surfaces/gui/package-lock.json + - name: npm ci + working-directory: surfaces/gui + run: npm ci + - name: Unit tests + working-directory: surfaces/gui + run: npm test + - name: Production dependency audit + working-directory: surfaces/gui + run: npm audit --omit=dev - link-agent: - runs-on: macos-14 - defaults: - run: - working-directory: link-agent-client + gui-e2e: + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 24 + node-version: 20 cache: npm - cache-dependency-path: link-agent-client/package-lock.json - - uses: dtolnay/rust-toolchain@stable - - run: npm ci - - run: npm run build - - run: cargo test --manifest-path src-tauri/Cargo.toml + cache-dependency-path: surfaces/gui/package-lock.json + - name: npm ci + working-directory: surfaces/gui + run: npm ci + - name: Install Playwright browsers + working-directory: surfaces/gui + run: npx playwright install --with-deps chromium + - name: e2e + working-directory: surfaces/gui + run: npm run e2e diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..97dd81a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,153 @@ +# Desktop release builds — macOS Apple Silicon (.dmg). +# +# CI calls the SAME packaging/build_dmg.sh script developers run locally; this file only +# provisions the toolchain (Node, Rust, a Python venv at .venv with PyInstaller) and +# publishes the result. +# +# Triggers: +# - tag push `v*` → builds all targets and attaches them to a DRAFT GitHub Release +# (review, then publish by hand). +# - manual run → builds and uploads workflow artifacts only (no release). +# +# Each installer is uploaded twice: once with its versioned name (archive) and once with a +# stable name (Smallink-macos-arm64.dmg, …) so the website can link to +# github.com//releases/latest/download/ +# and never need updating. Add Windows back only after packaging/build_windows.ps1 exists +# in the repository and is covered by a local or CI smoke test. +# +# macOS signing + notarization: Tauri's bundler handles both during `tauri build` when the +# APPLE_* env vars are present (import cert → sign app + sidecar with hardened runtime → +# notarize via notarytool → staple). Driven by repo secrets: +# APPLE_CERTIFICATE base64 .p12 (Developer ID Application cert + key) +# APPLE_CERTIFICATE_PASSWORD the .p12 export password +# APPLE_SIGNING_IDENTITY e.g. "Developer ID Application: Name (TEAMID)" +# APPLE_API_KEY_CONTENT base64 App Store Connect API .p8 (notarytool) +# APPLE_API_KEY the API key id +# APPLE_API_ISSUER the API issuer id +# When the secrets are absent (forks, scratch runs) the build degrades to unsigned — +# installable via `xattr -cr`. + +name: Release + +on: + push: + tags: ["v*", "app-v*"] + workflow_dispatch: + +defaults: + run: + shell: bash + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest # Apple Silicon + slug: macos-arm64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: surfaces/gui/package-lock.json + + - uses: dtolnay/rust-toolchain@stable + + - name: Install native build dependencies + run: brew install cmake + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: surfaces/gui/src-tauri + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Set up the sidecar venv (.venv) + # The build scripts expect a venv at .venv with the package + PyInstaller. + # typer/tzdata are build-time-only (PyInstaller walks mcp.cli, which needs typer; + # tzdata ships zoneinfo for Windows). aisuite installs like any other dependency + # (git-pinned in pyproject.toml). + run: | + python -m venv .venv + VPY=.venv/bin/python + "$VPY" -m pip install --upgrade pip + "$VPY" -m pip install -e . pyinstaller typer tzdata + "$VPY" -c "import aisuite, smallink" # fail fast if either import breaks + + - name: npm ci + working-directory: surfaces/gui + run: npm ci + + - name: Validate release version + run: python scripts/check_versions.py + + - name: Build .dmg + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY_CONTENT }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + # Unset empty APPLE_* vars so runs without secrets stay cleanly unsigned + # (Tauri treats a present-but-empty var as a config error). + for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_API_KEY APPLE_API_ISSUER; do + [ -n "$(eval echo "\${$v:-}")" ] || unset "$v" + done + if [ -n "${APPLE_API_KEY_CONTENT:-}" ]; then + echo "$APPLE_API_KEY_CONTENT" | base64 -d > "$RUNNER_TEMP/AuthKey.p8" + export APPLE_API_KEY_PATH="$RUNNER_TEMP/AuthKey.p8" + fi + unset APPLE_API_KEY_CONTENT + bash packaging/build_dmg.sh + + - name: Stage artifacts (versioned + stable names) + run: | + mkdir -p out + BUNDLE=surfaces/gui/src-tauri/target/release/bundle + cp "$BUNDLE"/dmg/*.dmg out/ + cp "$BUNDLE"/dmg/*.dmg out/Smallink-${{ matrix.slug }}.dmg + ls -la out + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.slug }} + path: out/* + if-no-files-found: error + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Validate release version + run: | + TAG="${GITHUB_REF_NAME}" + CONF_VERSION=$(python3 -c "import json; print(json.load(open('surfaces/gui/src-tauri/tauri.conf.json'))['version'])") + if [ "${TAG#v}" != "$CONF_VERSION" ]; then + echo "::error::tag $TAG != tauri.conf.json version $CONF_VERSION — bump the config before tagging" + exit 1 + fi + + - uses: softprops/action-gh-release@v2 + with: + draft: true + files: dist/* + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 450fec2..6f1da2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,52 +1,26 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -coverage -.output -.vinxi -.tanstack/** -.nitro -*.local -target/ -link-agent-client/src-tauri/target/ -src/lib/codex-history.generated.json -src/lib/codex-history.generated.ts - -# Local configuration and credentials -.env -.env.* -!.env.example -.dev.vars -*.pem -*.key - -# Local data, database exports and release artifacts -backups/ -*.dump -*.sql.gz -public/downloads/*.dmg -public/downloads/*.zip -*.app +.venv/ +.DS_Store +__pycache__/ +*.pyc +.pytest_cache/ +*.egg-info/ +build/ +dist/ +output/ +.coverage -# Wrangler / Cloudflare -.wrangler/ +# Local historical/reference material. These are not runtime dependencies and may +# contain machine-specific paths or third-party source snapshots. +archive/ +references/ -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? +# --- Build products (do NOT commit; regenerated by packaging/build_dmg.sh) --- +# PyInstaller freeze of the server sidecar + its staged copy inside the Tauri app. +packaging/dist/ +packaging/build/ +surfaces/gui/src-tauri/binaries/sidecar/ +# Tauri/Rust compile output (includes the final .app and .dmg) and Vite frontend build. +surfaces/gui/src-tauri/target/ +surfaces/gui/dist/ +# NOTE: surfaces/gui/src-tauri/icons/ and packaging/dmg-background.tiff ARE committed — +# they are build inputs the desktop bundle requires, not generated products. diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index be49b26..0000000 --- a/.prettierignore +++ /dev/null @@ -1,8 +0,0 @@ -node_modules -dist -.output -.vinxi -pnpm-lock.yaml -package-lock.json -bun.lock -routeTree.gen.ts diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 90abee2..0000000 --- a/.prettierrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "printWidth": 100, - "semi": true, - "singleQuote": false, - "trailingComma": "all" -} diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 933ce81..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,37 +0,0 @@ -# Changelog - -This file records user-visible changes to Link. The project is currently in developer preview, so interfaces and local data schemas may still change between minor releases. - -## [Unreleased] - -## [0.2.1] - 2026-07-19 - -### Added - -- LinkAgent menu-bar quick panel with device state, recent Codex sync, daily token usage, offline queue, and one-click sync. -- Daily Codex usage aggregation backed by a stable `usage` raw record. -- Repeatable private Alibaba Cloud ECS deployment and SSH tunnel scripts. -- Server-side proxy between Link Web and the independent crawler service. - -### Changed - -- Replaced the prototype build wrapper with a standard TanStack Start, Vite, React, Tailwind CSS, and Nitro Node production build. -- Moved the production container to Node.js 24 and a non-root runtime user. -- Updated the product and technical documents to describe the desktop companion and private-cloud boundary. - -### Removed - -- Removed the hosted prototype build wrapper, runtime-specific error hooks, and obsolete repository metadata. - -## [0.2.0] - 2026-07-18 - -### Added - -- Initial Link developer preview. -- Codex local-history intake through LinkAgent. -- Device pairing, heartbeat, incremental upload, offline batches, and source provenance browsing. -- Docker Compose stack for Link, PostgreSQL, and the independent web crawler. - -[Unreleased]: https://github.com/Jo-Tsu/link/compare/v0.2.1-developer-preview...HEAD -[0.2.1]: https://github.com/Jo-Tsu/link/releases/tag/v0.2.1-developer-preview -[0.2.0]: https://github.com/Jo-Tsu/link/releases/tag/v0.2.0-developer-preview diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..83b096b --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,241 @@ +# CHANGES — 记忆数据层管道(第一期)+ 打包修复 + +日期:2026-08-01 +作者:TRAE CLI 协作会话 + +> 本仓库当前不是 git 仓库(无 `.git`),无法直接 `git commit`。本文件是完整变更清单, +> 供在真实版本控制中提交时参考。文末附可复制的 commit message。 + +--- + +## 一、业务改动:记忆数据层管道(第一期) + +目标:打通「外部原始数据 → 不可变原始层 → 清洗·标记·分层 → 结构化记忆存储(pending)」。 +本期**不接入 agent 运行时**:不注入 prompt、不注册写工具、不改动正常对话上下文;管道产物停在 +`status='pending'`,等待后续确认闭环。 + +### 后端(Python) + +| 文件 | 状态 | 改动 | +|---|---|---| +| `smallink/memory/base.py` | 改 | `MemoryItem` 增 `status`/`updated_at`/`source_record_id`;`MemoryStore.add`、`list` 增对应参数,`list` 默认 `status="active"`(保证 pending 不泄漏进 prompt/确认视图) | +| `smallink/memory/sqlite_store.py` | 改 | 加法迁移新增 3 列(旧行自动回填 `active`,不丢数据);新建 `memory_history` 版本表;`add/update/delete` 各写一条 `ADD/UPDATE/DELETE` 历史;`list` 支持 status 过滤;新增 `list_history()` | +| `smallink/memory/pipeline.py` | **新增** | `MemoryPipeline`:一次 LLM 调用完成 清洗(提炼)→ 标记(10 种类型)→ 分层(scope);产出 `pending` + `source_record_id` 溯源;LLM 失败兜底(存原文,绝不抛错);靠 `source_record_id` 幂等。含 `MEMORY_TYPES`、`EXTRACTION_PROMPT` | +| `smallink/memory/__init__.py` | 改 | 导出 `MemoryPipeline`、`MEMORY_TYPES` | +| `smallink/server/manager.py` | 改 | 导入 `MemoryPipeline`;新增 `ingest_sensory_record`(复用 `SQLiteSensoryStore.add` + `_sensory_safe`,缺字段报错)、`run_memory_pipeline`;`list_memory` 支持 status 过滤(默认 active) | +| `smallink/server/app.py` | 改 | 新增 `POST /v1/sensory-records`(ingest,ValueError→400)、`POST /v1/memory/pipeline/run`(`asyncio.to_thread` 卸载阻塞 LLM);`GET /v1/memory` 增 `status` 查询参数(默认 active) | + +### 前端(TypeScript / React) + +| 文件 | 状态 | 改动 | +|---|---|---| +| `surfaces/gui/src/api.ts` | 改 | `MemoryRecord` 增 `status`/`updated_at`/`source_record_id`;`getMemory(status?)` 支持 active/pending/all | +| `surfaces/gui/src/components/MemoryView.tsx` | 改 | 新增只读「待确认(pending)」查看区:landing 页 banner 提示数量 → 点进只读列表 → 详情显示 类型/scope/状态。无确认/编辑按钮(确认闭环留下期)。新增 `typeLabel` 辅助 | +| `surfaces/gui/src/i18n.tsx` | 改 | 新增 pending 查看区相关中英文案(Awaiting confirmation、Review、Type、Status、Uncategorized 等) | + +### 测试 + +| 文件 | 状态 | 改动 | +|---|---|---| +| `tests/test_memory_pipeline.py` | **新增** | typed 抽取 / 多事实与 global scope / 坏 JSON 兜底 / provider 异常兜底 / 幂等 / 空 facts | +| `tests/test_sensory.py` | 改 | ingest 写入 / 幂等 / 缺字段 400 | +| `tests/test_memory.py` | 改 | 新增默认 active / pending 排除默认 list / 迁移回填 active / 版本历史 ADD-UPDATE-DELETE | +| `surfaces/gui/src/components/MemoryView.test.tsx` | 改 | 适配 getMemory(status) 双调用;新增 pending banner→review 视图测试、空队列隐藏 banner 测试 | + +### 文档 + +| 文件 | 状态 | 改动 | +|---|---|---| +| `docs/link-memory-technical-design.md` | 改 | 新增第 0 章「当前已实现(SQLite 过渡实现)」+ 与 Postgres 目标的映射表;保留目标章节不动 | +| `docs/link-memory-prd.md` | 改 | 加「当前进度」说明段 | + +### 测试结果 +- 后端:`929 passed, 1 skipped`(+13 新测试)。唯一失败 `tests/test_shell.py::test_timeout_kills_command` 是既有计时抖动(`4.05 < 4.0`),与本次无关。 +- 前端:`92 passed`(+2 MemoryView 测试)。`tsc` 严格类型检查 + `vite build` 通过。 + +--- + +## 一之二、Codex 数据连接器(把 Codex 对话导入记忆源) + +目标:给记忆库喂真实数据。新增 `codex` 本地连接器,把 `~/.codex/sessions/**/rollout-*.jsonl` +里的对话导入 `sensory_records`(本期已建的原始层),再由 pipeline 炼成记忆。粒度=**按对话轮次** +(一个 user 提问 + 其后连续的 assistant 回复合成一条,含完整问答上下文);接入=正式连接器 + 采集器; +范围支持"最近 N 个会话"先验证。 + +| 文件 | 状态 | 改动 | +|---|---|---| +| `smallink/connectors/codex_client.py` | **新增** | 解析 rollout JSONL:`session_meta` 取 session_id/cwd/timestamp/model;`response_item`+`message`+role∈{user,assistant} 取对话,`content` 的 input_text/output_text 拼文本;过滤 4 类审批协议噪声(见下);容错坏行;`read_sessions(limit=N)` 按文件名(时间)倒序取最近 N,支持 `$CODEX_HOME`;`CodexSession.turns()` 把消息聚合成"轮次"(U + 后续 A,`UAAUA→2轮`) | +| `smallink/server/manager.py` | 改 | 新增 `sync_codex(limit_sessions)`:读会话→**每个轮次**调 `ingest_sensory_record`(`source_type=codex`、`content_type=codex_turn`、`external_id=session:turn_index`、`raw_content` 为 `User:/Assistant:` 转录、带 cwd/conversation_id/roles 元数据);用 `count()` 前后差报告真实新增数(幂等) | +| `smallink/server/app.py` | 改 | 新增 `POST /v1/connectors/codex/sync`(`asyncio.to_thread` 卸载文件 I/O,`limit_sessions` 可选) | +| `smallink/connectors/descriptors.py` | 改 | 注册 `codex` 描述符(`auth="local_app"`、只读、无字段) | +| `smallink/connectors/catalog_copy.py` | 改 | 加 `codex` 的 ABOUT/ACCESS 文案(每个可用连接器必需,否则 test_connectors 失败) | +| `smallink/connectors/cli.py` | 改 | 新增 `sync-codex [--limit N]` 子命令(独立运行,不需起服务) | +| `surfaces/gui/src/components/connectors/visibility.ts` | 改 | 前端白名单加 `codex` | +| `tests/test_codex_connector.py` | **新增** | 解析(抽取/噪声过滤/坏行容错)、最近N倒序、轮次聚合、sync 按轮次入库 + 幂等,7 个用例 | + +**验证(本机真实数据)**:`sync_codex(limit=10)` → 2 个有效会话、11 个轮次、入库 11 条记录(每条含完整 +`User:…/Assistant:…` 问答);re-sync=0(幂等)。端到端:sync→pipeline→pending typed 记忆,确认视图仍为空 +(pending 隔离生效)。后端全套 `936 passed, 1 skipped`(+7 Codex 测试;唯一失败仍是既有 shell 计时抖动)。 + +**噪声过滤**:实测 10 会话 103 条消息中 **~57% 是 Codex 审批协议噪声**,`_is_noise` 按 4 类规则过滤: +① 纯 JSON 协议载荷(`{"outcome":"allow"}` 等审批判定)② 审批 preamble("The following is the Codex agent +history…")③ AGENTS.md/`` 脚手架 ④ 注入的 XML 上下文块(``/ +`` 等)。真实对话(含以 `# Files mentioned by the user:` 开头的正常 user 消息)保留。 +过滤后 0 噪声漏网(`test_noise_filters_drop_codex_protocol_traffic` 覆盖)。 + +--- + +## 一之三、Codex 文件夹授权(修复安装后"连不上") + +**问题**:安装后的 App 里 sync 返回 `sessions_read:0`,虽然 `~/.codex` 有 386 个文件。根因是 **macOS TCC 隐私 +保护**——ad-hoc 签名的 App 无权读主目录下的 `~/.codex`,`rglob` 被静默拒绝返回空(非代码 bug;`CODEX_HOME` +指向 `/tmp` 副本时同一 sidecar 能正常读,证明是权限)。 + +**方案 A(手动选目录授权)**:让用户在原生文件夹弹框里选 `~/.codex`——这个动作本身就让 macOS 授予读权限。 + +| 文件 | 状态 | 改动 | +|---|---|---| +| `smallink/connectors/codex_client.py` | 改 | 新增 `resolve_sessions_root(picked)`:把用户选的 `~/.codex` 规范到 `~/.codex/sessions`(或直接用已是 sessions 的目录);新增 `_MAX_ROLLOUT_BYTES=64MiB` 跳过异常巨大的 rollout(本机有个 1GB 文件),全量扫描 10s→0.8s | +| `smallink/connectors/setup.py` | 改 | `connect_connector` 的 `local_app` 分支放行 `codex`:把选中路径经 `resolve_sessions_root` 存进 `codex:default` 档案的 `sessions_path`(无 App 启动/健康检查,纯本地文件源) | +| `smallink/server/manager.py` | 改 | `sync_codex` 优先读 `codex:default` 的授权路径(`read_sessions(root=...)`),无授权时回退默认 `~/.codex/sessions` | +| `surfaces/gui/src/components/connectors/AddConnectionModal.tsx` | 改 | `LocalAppConnect` 对 `codex` 分流到新 `CodexConnect`:调 `chooseFolder()` 原生选目录 → `connectConnector(codex,{sessions_path})` | +| `surfaces/gui/src/components/connectors/ConnectorsSection.tsx` | 改 | 连接后详情页加 `CodexImportBlock`("导入对话"按钮调 `syncCodex()`,显示导入结果);状态行对 codex 显示"Codex 文件夹已连接" | +| `surfaces/gui/src/api.ts` | 改 | 新增 `syncCodex(limitSessions?)`;`connectConnector` 的 `fields` 传 `sessions_path` | +| `surfaces/gui/src/i18n.tsx` | 改 | 加 Codex 授权/导入相关中英文案 | +| `tests/test_codex_connector.py` | 改 | +路径规范化、+connect 存路径且 sync 读该路径、+超大文件跳过,共 10 个用例 | + +**验证(用打好的 .app 内 sidecar + 真实数据)**:connect `~/.codex` → 存 `sessions_path` → import → 读 17 会话、 +119 轮次、入库 105 条真实对话记录;`GET /v1/sensory-records?source_type=codex` total=105。后端全套 +`939 passed, 1 skipped`(唯一失败仍是既有 shell 计时抖动);前端 `92 passed` + `tsc`/`vite build` 通过。 + +**使用**:连接器 → Codex → 选择 Codex 文件夹(选 `~/.codex`,系统弹权限框须点允许)→ 导入对话。 + +--- + +## 一之四、模型用途标签(多选)+ pipeline 用途路由 + +**动机**:模型管理原本只有一个全局默认模型;记忆 pipeline 硬编码用全局默认,无法单独指定"记忆用哪个模型"。 +照现有模型管理形态,给每个模型加**"使用用途"多选标签**(chat/memory/title),pipeline 用打了 `memory` +用途的模型——顺带补掉"无独立记忆模型设置"的缺口。 + +| 文件 | 状态 | 改动 | +|---|---|---| +| `smallink/server/manager.py` | 改 | 加 `MODEL_PURPOSES=("chat","memory","title")`;`set_model_purposes(model,purposes)`(存 `prefs.json` 的 `model_purposes`,多选,未知用途剔除,清空即删条目);`model_for_purpose(purpose)`(取标注该用途的模型,多个时优先默认、否则第一个,无则回退全局默认);`get_settings` 返回 `model_purposes`+`purposes`;`run_memory_pipeline` 改用 `model_for_purpose("memory")` | +| `smallink/server/app.py` | 改 | 加 `POST /v1/settings/model-purposes {model,purposes}` | +| `surfaces/gui/src/api.ts` | 改 | `ModelSettings` 加 `model_purposes`/`purposes`;`setModelPurposes(model,purposes)` | +| `surfaces/gui/src/components/ModelChecklist.tsx` | 改 | 每个模型行加用途多选 chip(对话/记忆/标题),点击调 `setModelPurposes`,乐观更新 | +| `surfaces/gui/src/components/ManageTabs.tsx` | 改 | 传 `purposes`/`modelPurposes` 给 `ModelChecklist` | +| `surfaces/gui/src/styles.css` · `i18n.tsx` | 改 | chip 样式 + 中英文案("For chat/For memory/For titles",避开与既有键冲突) | +| `tests/test_model_purposes.py` | **新增** | 存取/未知剔除/清空删条目/解析(标注·多标注取默认·回退)/pipeline 用 memory 模型/未标注回退,6 用例 | + +**本期只接 memory**:`chat`/`title` 标签可存但暂不接线(对话/标题主链不动,降风险)。**验证**:.app 内 sidecar +设 `ollama:qwen3→[memory,chat]` → `/v1/settings` 回读正确 → pipeline 实测调用该模型(`test_pipeline_uses_memory_tagged_model`)。 +后端 `945 passed, 1 skipped`(+6);前端 `92 passed` + build 通过。 + +**使用**:设置 → 模型 → 每个模型行勾选"用于记忆"→ 该模型即用于记忆 pipeline(未勾选任何模型时回退默认模型)。 + +--- + +## 一之五、Codex 一键授权、连接和导入 + +**问题**:此前 CLI 的连接流程只写了一半(`_cmd_sync_codex` 已接受 folder/reconnect,但 argparse 未暴露参数且 +调用签名不匹配);应用内也仍是“先选目录连接,再到详情页点导入”两步。 + +**实现**: + +| 文件 | 状态 | 改动 | +|---|---|---| +| `smallink/connectors/cli.py` | 改 | `sync-codex` 新增 `--folder`、`--reconnect`;无已授权路径时自动打开原生文件夹选择器,选择后持久化 connector profile,再立即导入;已连接时直接增量导入 | +| `scripts/connect_codex.sh` | **新增** | 一键脚本,调用 `.venv/bin/smallink-connectors sync-codex`;默认弹原生目录授权框,也可传 `--folder ~/.codex --limit N --reconnect` | +| `surfaces/gui/src/components/connectors/AddConnectionModal.tsx` | 改 | Codex 连接按钮改为“授权并导入”:原生选目录 → connect → sync,一次完成;显示授权/导入进度和最终计数 | +| `surfaces/gui/src/i18n.tsx` | 改 | 一键授权、连接、导入相关中英文案 | + +**验证**:`scripts/connect_codex.sh --folder ~/.codex --limit 30` 首次读 3 有效会话、13 轮次、入库 13 条; +同状态目录再次执行新增 0(幂等)。前端 build、92 tests;相关后端 73 tests。 + +--- + +## 一之六、TraeX / TRAE CLI 本地对话连接器 + +**问题**:此前只有 Codex 连接器扫描 `~/.codex/sessions`,本机 TraeX / TRAE CLI 的会话实际存放在 +`~/.trae/cli/sessions`,因此 TRAE 对话从未进入 Smallink 原始数据池。 + +**实现**: + +- 新增 `traex` 独立连接器和 `POST /v1/connectors/traex/sync`,以 `source_type='traex'`、 + `content_type='traex_turn'` 写入 `sensory_records`,不再错误标记为 Codex。 +- 复用 rollout JSONL 解析器,但只导入 `thread_source=user`(或旧版缺失该字段)的主会话; + 排除子智能体内部线程、权限说明、系统提醒和旧版日期/时区提示。 +- 只导入已有 `task_complete` 的轮次;正在追加的当前轮次等待完成后再增量同步,避免同一轮产生多个内容版本。 +- 支持 GUI 一键授权 `~/.trae`、连接并导入,也支持 + `smallink-connectors sync-traex --folder ~/.trae`。 +- 连接器列表和详情页复用本地会话源交互,显示“就绪”而不是错误的“已停止”。 +- 记忆首页新增原始源数据、TRAE 数据和 Codex 数据计数,区分“已采集”与“已生成候选记忆”。 + +**真实数据验证**:本机发现 52 个 TRAE rollout 文件,其中 12 个用户主会话、40 个子智能体线程。 +完成边界过滤后导入 72 个已完成轮次;再次同步新增 0。试同步阶段产生的 10 条未完成/旧版本记录已精确清理, +且没有任何一条被记忆管道引用。 + +**测试**:后端连接器与注册表聚焦测试 `80 passed`,TraeX/Codex 回归 `15 passed`;前端相关组件 +`14 passed`,`tsc && vite build` 通过。 + +--- + +## 二、打包修复:补齐仓库缺失的构建文件 + +打包时发现以下构建文件**从未提交进仓库**,导致 `packaging/build_dmg.sh` 无法完成。这是仓库既有 +缺陷(非本次业务改动引入),已补齐使打包可复现: + +| 文件 | 状态 | 说明 | +|---|---|---| +| `packaging/smallink-server.spec` | **新增** | PyInstaller 冻结配置;`build_dmg.sh` 依赖它但文件缺失。用 `collect_all` 收集 providers/mcp/ddgs 等的数据与动态导入,捆绑内置 persona,`LINK_EXPERIMENTAL` 未设时排除实验连接器。**修复**:`smallink` 是 editable 安装(自定义 MetaPathFinder),PyInstaller 静态分析跟不进去,导致冻结包里只有 personas/、缺 server/memory/connectors 等模块,运行时 `ModuleNotFoundError: smallink.server`——改为 `pathex=绝对源码根` + 把整个 `smallink`/`link` 源码树作为 datas 打入,并已用 .app 内 sidecar 实测启动成功(health 200、codex 在 catalog)。 | +| `packaging/entry_smallink_server.py` | **新增** | PyInstaller 入口 shim(`run.py` 用相对导入,不能直接作冻结入口) | +| `surfaces/gui/src-tauri/build.rs` | **新增** | Tauri 构建脚本 `fn main(){ tauri_build::build(); }`;`Cargo.toml` 声明了 `tauri-build` 却缺 build.rs,导致 `generate_context!` 报 `OUT_DIR not set` | +| `surfaces/gui/src-tauri/icons/*` | **新增** | 由 `npx tauri icon design/brand/smallink-mark.png` 生成的完整图标集(19 个文件,构建必需,`tauri.conf.json` 的 `bundle.icon` 引用) | +| `packaging/dmg-background.tiff` | **新增** | DMG 安装窗口背景图(HiDPI 1x+2x TIFF),装饰用;`build_dmg.sh` 硬引用它 | + +### 产物 +`surfaces/gui/src-tauri/target/release/bundle/dmg/Smallink_0.1.6_aarch64.dmg`(56M,Apple +Silicon,本地 ad-hoc 签名,未公证)。**仅供本地,不可分发**;安装需 `xattr -cr /Applications/Smallink.app`。 + +### 环境备注(不入库) +- venv 装了 `pyinstaller tzdata typer cmake`(构建期依赖;`cmake` 供 whisper-rs 编译 STT)。 +- `hdiutil` 因沙箱限制无法建磁盘镜像,最终 DMG 封装在关闭沙箱下完成。 + +--- + +## 三、不应提交的内容(构建产物 / 环境) + +以下由构建生成,**不要提交**(建议加入 `.gitignore`): +- `packaging/dist/`、`packaging/build/`(PyInstaller 输出) +- `surfaces/gui/src-tauri/binaries/sidecar/`(冻结的 server + 数千个第三方库文件) +- `surfaces/gui/src-tauri/target/`(Rust/Tauri 编译产物,含最终 DMG) +- `surfaces/gui/dist/`(Vite 前端构建) +- `.venv/`、`surfaces/gui/node_modules/`、各 `__pycache__/` + +--- + +## 四、可复制的 commit message + +``` +feat(memory): 记忆数据层管道(ingest → 清洗·标记·分层 → pending 存储) + +打通外部原始数据到结构化记忆的数据层,不接入对话运行时: +- 新增通用 ingest 端点 POST /v1/sensory-records(写入不可变 sensory_records,幂等) +- 新增 MemoryPipeline:LLM 提炼→类型标记→scope 分层,产出 status='pending' 记忆, + 带 source_record_id 溯源与 LLM 失败兜底;POST /v1/memory/pipeline/run 手动触发 +- 扩展 SQLiteMemoryStore:status/updated_at/source_record_id 列(加法迁移,旧行回填 active) + + memory_history 版本表;list 默认 active(pending 不泄漏进 prompt/确认视图) +- 前端 MemoryView 新增只读「待确认」查看区(无确认/编辑按钮,确认闭环留后期) +- 文档:记忆技术方案新增「当前 SQLite 过渡实现」章节与目标映射 + +fix(packaging): 补齐缺失的构建文件使桌面打包可复现 + +补齐从未提交的构建产物依赖(既有缺陷,非本次业务引入): +PyInstaller spec + 入口 shim、Tauri build.rs、图标集、DMG 背景图。 +产出未签名本地 DMG(Smallink_0.1.6_aarch64.dmg)。 + +测试:后端 929 passed / 前端 92 passed(tsc+vite build 通过)。 + +Co-authored-by: TRAE CLI +``` diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 08123ec..80d11b3 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,6 +1,6 @@ # Code of Conduct -Link 社区希望讨论保持专业、友善和以问题为中心。 +Smallink 社区希望讨论保持专业、友善和以问题为中心。 - 尊重不同经验、背景和观点。 - 对代码和设计提出具体、可验证的反馈,不攻击个人。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f903ebb..dba1ade 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,26 +1,30 @@ # Contributing -感谢关注 Link。当前项目处于数据接入闭环的开发预览阶段,请让改动保持小而清晰,不要在同一提交中加入数据治理、记忆、知识库或多租户实现。 +感谢关注 Smallink。请让改动保持边界清楚,并同步更新受影响的产品、技术与测试文档。 ## 开发流程 1. 从 `main` 创建短生命周期分支。 -2. 不提交真实 Codex 数据、数据库导出、凭据、绝对个人路径或安装包。 +2. 不提交真实对话、数据库导出、凭据、绝对个人路径、构建目录或安装包。 3. 更新与行为变化对应的 PRD 和技术文档。 -4. 提交前运行平台与 LinkAgent 验证。 +4. 提交前运行服务端、桌面端和端到端验证。 ```bash +python -m pip install -e ".[messaging,dev]" +pytest tests -q + +cd surfaces/gui npm ci -npm run lint +npm test npm run build -npm --prefix link-agent-client ci -npm --prefix link-agent-client run build -cd link-agent-client/src-tauri && cargo test +npm run e2e + +cargo check --manifest-path surfaces/gui/src-tauri/Cargo.toml ``` ## Pull Request -PR 需要说明目标、行为变化、验证方式和隐私影响。新增连接器必须写入统一原始池并保留来源追溯,不得绕过接入协议写入后续数据资产。 +PR 需要说明目标、行为变化、验证方式和隐私影响。新增连接器、智能体、记忆写入或工具能力必须遵循既有边界,并保留来源追溯与权限控制。 ## Contributor Agreement diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 7c38d17..0000000 --- a/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -FROM node:24-bookworm-slim AS build - -WORKDIR /app - -COPY package*.json ./ -RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund - -COPY . . -RUN npm run build - -FROM node:24-bookworm-slim AS runtime - -ENV NODE_ENV=production \ - HOST=0.0.0.0 \ - PORT=41737 - -WORKDIR /app - -COPY --chown=node:node --from=build /app/.output ./.output - -USER node - -EXPOSE 41737 - -CMD ["node", ".output/server/index.mjs"] diff --git a/LICENSE b/LICENSE index 66188b4..92eaa09 100644 --- a/LICENSE +++ b/LICENSE @@ -4,7 +4,7 @@ Parameters Licensor: Jo-Tsu -Licensed Work: Link 0.2.1 +Licensed Work: Smallink 0.2.0 The Licensed Work is (c) 2026 Licensor. Additional Use Grant: You may make production use of the Licensed Work for diff --git a/NOTICE b/NOTICE index 52b486f..92aa054 100644 --- a/NOTICE +++ b/NOTICE @@ -1,4 +1,4 @@ -Link 0.2.1 Developer Preview +Smallink 0.2.0 Copyright (c) 2026 Jo-Tsu Unless a file or directory contains a different license notice, the source code @@ -7,5 +7,5 @@ in this repository is licensed under the Business Source License 1.1 in LICENSE. Third-party packages and container images remain subject to their respective licenses. Their inclusion does not change the license of those components. -The names Link and LinkAgent and the project logos are not licensed for use as +The name Smallink and the project logos are not licensed for use as the name or branding of a derived product. See TRADEMARKS.md. diff --git a/PROJECT_LAYOUT.md b/PROJECT_LAYOUT.md new file mode 100644 index 0000000..7778030 --- /dev/null +++ b/PROJECT_LAYOUT.md @@ -0,0 +1,40 @@ +# Smallink 项目目录说明 + +当前目录是 Smallink 的唯一开发主目录。初始化或恢复 Git 元数据后,也应以此目录作为仓库根目录。 + +## 当前主工程 + +| 目录 | 内容 | +|---|---| +| `smallink/` | Python 后端、智能体、连接器、记忆与任务运行的唯一正式实现 | +| `link/` | 旧版 Python 导入兼容转发层,不承载业务实现,新功能禁止写入 | +| `surfaces/gui/` | Web 与 macOS 桌面客户端 | +| `stt/` | 本地语音输入模块 | +| `tests/` | 后端测试 | +| `docs/` | 当前 PRD、技术方案和产品文档 | +| `design/brand/` | Smallink 字标源文件、独立图形与品牌使用规则 | +| `packaging/` | macOS 与 Windows 打包脚本和产物 | +| `.venv/` | 当前项目 Python 环境 | + +## 参考与历史资料 + +| 目录 | 内容 | 使用边界 | +|---|---|---| +| `references/mem0-main/` | mem0 参考源码 | 只用于设计参考,不作为 Link 运行依赖 | +| `archive/legacy-memory-prototype/` | 早期个人知识与记忆原型 | 只读历史资料,不作为当前产品事实源 | + +## 不放入源码目录的数据 + +以下内容属于运行数据或用户数据,不能与源码、参考项目和构建产物混在一起: + +- `~/.config/link/`:数据库、设置、密钥、日志和模型等本机状态。 +- `~/Smallink/`:新建用户会话产生的默认工作目录和产物;已有偏好中的旧路径继续有效。 +- 各项目自身的 `.link/`:该工作区的 Link 配置。 + +这些目录由 Link 在运行时管理。迁移或备份时应单独处理,不能提交到源码仓库。 + +## 命名空间规则 + +- 新增后端功能、连接器、工具、智能体、任务与记忆代码只能进入 `smallink/`。 +- 正式 Python 导入统一使用 `smallink.*`。 +- `link/`、`link-server`、`X-Link-Token` 和既有状态目录仅用于兼容历史安装;兼容层不能复制或分叉业务逻辑。 diff --git a/README.en.md b/README.en.md deleted file mode 100644 index c3d451d..0000000 --- a/README.en.md +++ /dev/null @@ -1,171 +0,0 @@ -
- -# Link - -**Bring the work history scattered across local AI tools back into a data foundation you control.** - -Link is a local-first data intake platform. With explicit user permission, LinkAgent reads local sources such as Codex, preserves their raw context and provenance, and writes recoverable incremental batches into PostgreSQL. - -[![CI](https://github.com/Jo-Tsu/link/actions/workflows/ci.yml/badge.svg)](https://github.com/Jo-Tsu/link/actions/workflows/ci.yml) -[![Preview release](https://img.shields.io/github/v/release/Jo-Tsu/link?include_prereleases&label=preview)](https://github.com/Jo-Tsu/link/releases) -[![Source available](https://img.shields.io/badge/source-available-16803a)](LICENSE) -[![macOS Apple Silicon](https://img.shields.io/badge/LinkAgent-macOS%20Apple%20Silicon-111827)](https://github.com/Jo-Tsu/link/releases) - -[Download LinkAgent](https://github.com/Jo-Tsu/link/releases/tag/v0.2.1-developer-preview) · [Quick start](#quick-start) · [Product spec](docs/data-intake-prd.md) · [Changelog](CHANGELOG.md) · [简体中文](README.md) - -
- -![Link data overview](docs/assets/link-data-overview.png) - -> The screenshot uses synthetic demo data and contains no real user content. - -## Preserve facts before deciding how to interpret them - -AI conversations, CLI commands, tool calls, and project context are becoming some of the most valuable and most fragmented parts of personal work. They are often trapped in local directories, one-off sessions, or separate products. Summarizing them too early removes the evidence that made them useful. - -Link does not start as another note-taking app. It starts with a dependable data foundation: - -- **Connect locally**: LinkAgent reads only explicitly authorized sources on the user's device. -- **Keep the raw facts**: preserve inputs, outputs, CLI, tools, and skill references before transformation. -- **Trace every record**: navigate back to its connector, source container, original session, and timestamp. -- **Resume safely**: cursors, idempotent batches, and a durable offline queue make synchronization retryable. - -## What makes Link different - -| Principle | How Link applies it | -| --- | --- | -| Local-first instead of pretending every source lives in the cloud | LinkAgent owns local authorization and extraction and only makes outbound connections | -| Raw-first instead of immediately hiding data behind an AI summary | PostgreSQL `sensory_records` preserves facts and provenance metadata | -| One connector model instead of one concept per product | Codex, Feishu, local files, and future sources share containers, records, cursors, and sync results | -| Observable instead of a sync button with no explanation | The platform exposes connectors, source containers, raw records, timestamps, and sync state | - -## What works today - -- Read the complete local Codex history across projects in read-only mode. -- Extract user input, Codex output, CLI commands, tool calls, and skill references. -- Aggregate daily Codex token usage into a stable `usage` raw record. -- Pair LinkAgent devices, send heartbeats, sync incrementally, and retry from an offline queue. -- Browse data as connector -> source container -> raw record. -- Capture web sources through an independent crawler service. -- Run Link, PostgreSQL, and the crawler locally with Docker Compose. - -The current release is `0.2.1 Developer Preview`. Feishu, local files, scheduled sync, multi-tenancy, governance, memory, and retrieval are not shipped yet. The roadmap below is intentionally not presented as finished functionality. - -## How it works - -```mermaid -flowchart LR - subgraph Device["User device"] - Source["Codex / future connectors"] --> Agent["LinkAgent"] - Agent --> Queue["Cursor + local credentials + offline queue"] - end - - Queue -->|"Outbound HTTPS / idempotent batches"| API["Link Platform API"] - API --> Raw["PostgreSQL raw pool"] - Web["Link Web"] --> API - Crawler["Independent web crawler"] --> API -``` - -The platform never mounts the user's `~/.codex` directory and does not require an inbound port on the user's machine. LinkAgent credentials stay in the local Keychain, while raw records are stored in a PostgreSQL instance controlled by the user. - -## Quick start - -### 1. Run the Link platform - -Docker Desktop is required: - -```bash -git clone https://github.com/Jo-Tsu/link.git -cd link -cp .env.example .env -docker compose up -d --build -``` - -Open [http://127.0.0.1:41737](http://127.0.0.1:41737). Ports bind to `127.0.0.1` by default. - -### 2. Install LinkAgent - -Download the latest Apple Silicon DMG from [GitHub Releases](https://github.com/Jo-Tsu/link/releases). The current preview uses an ad-hoc signature and has not been notarized by Apple. - -### 3. Pair and sync - -1. In Link, open Settings -> Agent -> LinkAgent and create a one-time pairing code. -2. Open LinkAgent and enter the platform URL and pairing code. -3. Authorize the Codex connector and run the first sync. -4. Return to Data and open the Codex card to browse source containers and raw records. - -See [local stable operation](docs/local-stable-run.md) for the complete workflow. - -## LinkAgent as a desktop companion - -LinkAgent is more than a background script. It provides three levels of interaction: - -- **Menu bar presence** for online, syncing, and attention-required states. -- **Quick panel** for recent syncs, today's token usage, offline queue, and one-click Codex sync. -- **Management window** for pairing, connector authorization, sync history, and local settings. - -Permissions and credentials remain local. Failed batches stay in a durable queue and can continue after connectivity returns. - -## Privacy and security boundaries - -- Every connector requires explicit user authorization and an observable read scope. -- LinkAgent only makes outbound connections and opens no local service port. -- The platform receives normalized raw records, not Codex login credentials or API keys. -- Logs, screenshots, and issues must be scrubbed of tokens, private paths, and raw conversations. -- The current preview has no public user authentication or tenant isolation and **must not be exposed directly to the internet**. - -For cloud testing, use the [private Alibaba Cloud deployment](docs/aliyun-private-deployment.md) through an SSH tunnel while keeping Link, PostgreSQL, and crawler ports closed. - -## Roadmap - -| Stage | Focus | Status | -| --- | --- | --- | -| `0.2.x` | Codex raw-data loop, LinkAgent, provenance browser, and web capture | In progress | -| Next | Configurable scheduled sync, usage scheduler, connector SDK, and agent upgrades | Planned | -| More connectors | Feishu, local files, and additional local tools | Planned | -| Data value layer | Governance, AI extraction, personal memory, and retrieval | After the intake foundation stabilizes | -| SaaS | Email login, tenant isolation, mobile, and production cloud deployment | Not in active development | - -## Repository layout - -| Path | Contents | -| --- | --- | -| `src/` | Link Web, platform API, and PostgreSQL access | -| `link-agent-client/` | Tauri + Rust + React LinkAgent client | -| `crawler_backend/` | Independent web crawler service | -| `docs/` | Product, technical, deployment, and operations documentation | -| `deploy/aliyun/` | Private ECS test deployment scripts | - -Platform checks: - -```bash -npm ci -npm run lint -npm run build -``` - -LinkAgent checks: - -```bash -cd link-agent-client -npm ci -npm run build -cd src-tauri -cargo test -``` - -## Contributing - -- Found a bug? Open a [bug report](https://github.com/Jo-Tsu/link/issues/new?template=bug_report.yml). -- Have a connector or product proposal? Open a [feature request](https://github.com/Jo-Tsu/link/issues/new?template=feature_request.yml). -- Want to discuss workflows or product direction? Join [GitHub Discussions](https://github.com/Jo-Tsu/link/discussions). -- Before contributing, read [CONTRIBUTING.md](CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). -- Report vulnerabilities privately as described in [SECURITY.md](SECURITY.md); never post sensitive logs publicly. - -A CLA is not enabled yet, so substantial external code contributions cannot be merged until that process exists. Reproducible issues, product discussion, design feedback, and documentation improvements are welcome today. - -## License - -Link is source available under the [Business Source License 1.1](LICENSE). Personal use, development, testing, and internal business use are allowed, but offering Link's primary functionality as a hosted or managed third-party service requires commercial permission. Version `0.2.1` is scheduled to change to Apache-2.0 on `2030-07-19`. - -BSL is not an OSI-approved open-source license. We use the precise term `source available` instead of hiding the usage boundary behind ambiguous wording. diff --git a/README.md b/README.md index d75e47f..44fb5e9 100644 --- a/README.md +++ b/README.md @@ -1,171 +1,173 @@
-# Link +# Smallink -**把散落在本机 AI 工具里的工作历史,接回到你自己的数据底座。** +**一个在个人电脑上运行、能连接数据、调用真实工具,并在用户控制下形成长期记忆的个人 AI 工作系统。** -Link 是一个本地优先的数据接入平台。LinkAgent 在用户明确授权后读取 Codex 等本地来源,保留原始上下文、工具调用与来源关系,并以可追溯的增量批次写入 PostgreSQL。 +Local-first personal AI work, data, knowledge, and memory system. [![CI](https://github.com/Jo-Tsu/link/actions/workflows/ci.yml/badge.svg)](https://github.com/Jo-Tsu/link/actions/workflows/ci.yml) -[![Preview release](https://img.shields.io/github/v/release/Jo-Tsu/link?include_prereleases&label=preview)](https://github.com/Jo-Tsu/link/releases) -[![Source available](https://img.shields.io/badge/source-available-16803a)](LICENSE) -[![macOS Apple Silicon](https://img.shields.io/badge/LinkAgent-macOS%20Apple%20Silicon-111827)](https://github.com/Jo-Tsu/link/releases) +[![License: BSL 1.1](https://img.shields.io/badge/license-BSL%201.1-16803a)](LICENSE) +[![Desktop](https://img.shields.io/badge/desktop-Tauri%20%2B%20React-111827)](surfaces/gui) -[下载 LinkAgent](https://github.com/Jo-Tsu/link/releases/tag/v0.2.1-developer-preview) · [三分钟启动](#三分钟启动) · [产品文档](docs/data-intake-prd.md) · [版本记录](CHANGELOG.md) · [English](README.en.md) +[产品架构](docs/smallink-architecture-guide.md) · [产品需求](docs/link-product-prd.md) · [技术设计](docs/link-technical-design.md) · [当前记忆逻辑与结构](docs/current-memory-product-logic-and-structure.md) · [配置示例](docs/config.example.toml)
-![Link 基础数据概览](docs/assets/link-data-overview.png) +## Smallink 是什么 -> 截图使用合成演示数据,不包含真实用户内容。 +Smallink 不只是一个聊天客户端,也不只是一个知识库。它把三件事放进同一条本地优先的产品主线: -## 先保存事实,再决定如何理解它 +- **完成真实工作**:智能体使用模型、文件、终端、浏览器、Skill、MCP 和连接器交付代码、文档与其他产物。 +- **保留原始事实**:会话、工具调用、文件和连接器数据先进入可追溯的原始数据层,AI 不能覆盖来源。 +- **形成长期能力**:原始事实经 AI 治理与人工确认后形成记忆和知识,供未来任务按需检索。 -AI 对话、CLI 命令、工具调用和项目上下文正在成为个人工作中最有价值、也最容易散落的一类数据。它们通常被锁在不同产品、本机目录或一次性会话中;直接总结又会丢掉来源和证据。 +产品当前首先服务单个用户和个人电脑。默认本地运行,关键操作需要确认;远程 Worker、多设备同步和协作是未来扩展方向,不以牺牲本地模式为前提。 -Link 不从“再做一个笔记应用”开始。它先建立一层稳定的数据底座: +## 产品架构 -- **连接本机**:LinkAgent 在设备侧读取获得授权的数据,平台不反向扫描用户电脑。 -- **原始保真**:先保存输入、输出、CLI、工具和技能线索,再进行后续加工。 -- **完整追溯**:每条记录都能回到连接器、来源容器、原始会话和发生时间。 -- **增量可恢复**:游标、幂等批次和离线队列让同步可以重试,而不是每次重新导入。 - -## Link 的不同之处 - -| 设计原则 | Link 如何实现 | -| --- | --- | -| 本地优先,而不是假装所有数据都在云端 | LinkAgent 负责本地授权、读取和清洗,只建立出站连接 | -| 原始数据优先,而不是先交给 AI 黑盒总结 | PostgreSQL `sensory_records` 保存未经加工的事实与来源元数据 | -| 一个连接器模型,而不是为每个产品发明一套概念 | Codex、飞书、本地文件和未来来源统一输出容器、记录、游标与同步结果 | -| 可观察,而不是“点击同步后不知道发生了什么” | 平台展示连接器、来源容器、原始记录、时间和同步状态 | - -## 当前可以做什么 - -- 只读接入完整 Codex 本地历史,而不局限于某个项目。 -- 提取用户输入、Codex 输出、CLI、工具调用和技能线索。 -- 按本机自然日汇总 Codex Token 用量,并写入稳定的 `usage` 原始记录。 -- 通过 LinkAgent 完成设备配对、心跳、增量同步和离线重试。 -- 在 Web 端按“连接器 -> 来源容器 -> 原始记录”浏览和追溯数据。 -- 使用独立网页采集器保存网页来源。 -- 通过 Docker Compose 在本机稳定运行 Link、PostgreSQL 与 Crawler。 +```mermaid +flowchart TB + subgraph UX["体验层"] + Workbench["工作台"] + Projects["项目空间"] + Sources["数据来源"] + GovernanceUI["数据治理"] + MemoryUI["个人记忆与知识库"] + AutomationUI["自动化"] + Inbox["待处理中心"] + Settings["设置"] + end -当前版本是 `0.2.1 Developer Preview`。飞书、本地文件、自动定时同步、多租户、数据治理、记忆和检索尚未交付;路线图不会伪装成现成功能。 + subgraph Control["控制层"] + Task["Task / TaskRun"] + Orchestration["多智能体编排"] + Policy["权限、风险与人工确认"] + end -## 工作原理 + subgraph Execution["执行层"] + Runtime["智能体运行内核"] + Models["模型网关"] + Capabilities["Tool / Skill / MCP"] + Connectors["连接器"] + end -```mermaid -flowchart LR - subgraph Device["用户设备"] - Source["Codex / 后续连接器"] --> Agent["LinkAgent"] - Agent --> Queue["游标 + 加密凭据 + 离线队列"] + subgraph Intelligence["数据智能层"] + Raw["原始事实池"] + Governance["AI 治理"] + Memory["正式记忆"] + Knowledge["知识索引"] + Context["任务上下文"] end - Queue -->|"出站 HTTPS / 幂等批次"| API["Link Platform API"] - API --> Raw["PostgreSQL 原始池"] - Web["Link Web"] --> API - Crawler["独立网页采集器"] --> API + Workbench --> Task --> Orchestration --> Runtime + AutomationUI --> Task + Policy --> Runtime + Inbox --> Policy + Runtime --> Models + Runtime --> Capabilities --> Connectors + Runtime --> Raw + Connectors --> Raw + Raw --> Governance --> Memory --> Context --> Runtime + Raw --> Knowledge --> Context ``` -平台不挂载用户的 `~/.codex`,也不要求在用户设备上开放入站端口。LinkAgent 凭据保存在本机 Keychain,原始数据进入用户自己控制的 PostgreSQL。 +完整产品包含三条闭环:目标到产物的工作闭环、事实到记忆的数据学习闭环,以及定时或事件触发的自动化闭环。 + +## 当前能力 + +已经进入真实代码和数据链路的能力包括: + +- Tauri + React 桌面客户端与浏览器开发界面; +- Python + FastAPI 本地核心服务; +- OpenAI、Anthropic、Gemini 等多模型 Provider; +- 多轮工具执行、流式事件和高风险操作人工审批; +- 文件、Shell、Git、网页、浏览器、PDF、Skill 与 MCP 能力; +- 会话、任务运行、产物、Inbox、自动化和基础子智能体; +- Slack、GitHub、Gmail、Google Calendar、HubSpot 等连接器基础; +- 本地状态、工作区信任、密钥和权限管理。 + +正在建设的正式主链包括项目空间、通用数据治理、带来源与版本的正式记忆、知识索引,以及向 Postgres + pgvector 的统一数据迁移。正式页面只展示真实能力,不使用 Mock 数据伪装完成状态。 + +## 技术架构 + +```text +Smallink Desktop (Tauri + React) + | + | HTTP + WebSocket / 每次启动独立令牌 + v +Smallink Core (Python + FastAPI) + | + +-- Agent Runtime / TaskRun / AgentRun + +-- Model Providers / Tools / Skills / MCP + +-- Permissions / Approvals / Inbox + +-- Connectors / Sensory Records + +-- Automations / Sessions / Artifacts + +-- Memory / Knowledge(持续建设) + | + v +Postgres + pgvector(目标业务事实源) +``` -## 三分钟启动 +迁移期间,部分运行历史和偏好仍使用 SQLite、JSONL 或 JSON。旧 `link` Python 导入、命令、Header、WebSocket 子协议和本机状态目录仅作为非破坏性兼容标识保留;所有新实现统一进入 `smallink` 包。 -### 1. 启动 Link 平台 +## 本地开发 -需要 Docker Desktop: +需要 Python 3.10+、Node.js;运行桌面壳还需要 Rust 与 Cargo。 ```bash -git clone https://github.com/Jo-Tsu/link.git -cd link -cp .env.example .env -docker compose up -d --build +python3 -m venv .venv +.venv/bin/python -m pip install --upgrade pip +.venv/bin/python -m pip install -e ".[messaging,dev]" ``` -打开 [http://127.0.0.1:41737](http://127.0.0.1:41737)。默认端口只绑定到 `127.0.0.1`。 - -### 2. 安装 LinkAgent - -从 [GitHub Releases](https://github.com/Jo-Tsu/link/releases) 下载最新的 Apple Silicon DMG。当前预览包使用 ad-hoc 签名,尚未进行 Apple 公证。 - -### 3. 连接并同步 - -1. 在 Link 的“设置 -> 智能体 -> LinkAgent”中创建一次性配对码。 -2. 打开 LinkAgent,输入平台地址与配对码。 -3. 授权 Codex 连接器并执行首次同步。 -4. 回到“基础数据”,从 Codex 卡片进入来源容器和原始记录。 - -详细步骤见 [本地稳定运行](docs/local-stable-run.md)。 - -## LinkAgent 桌面伴生体验 +启动本地核心服务: -LinkAgent 不是要求用户长期盯着的后台脚本。它提供三层交互: - -- **菜单栏入口**:快速感知在线、同步中和需要处理的状态。 -- **快捷浮层**:查看最近同步、今日 Token、离线队列并一键同步 Codex。 -- **完整管理窗口**:管理设备配对、连接器授权、同步记录和本机设置。 - -权限和凭据只保存在本机;同步失败的批次进入持久队列,恢复连接后可以继续上传。 - -## 隐私与安全边界 - -- 连接器必须由用户显式授权,并展示实际读取范围。 -- LinkAgent 只建立出站连接,不开放本机服务端口。 -- 平台只接收标准化原始记录,不接收 Codex 登录凭据或 API Key。 -- 日志、截图和 Issue 在提交前必须去除令牌、私有路径和原始对话。 -- 当前版本没有公网用户认证和多租户隔离,**不得直接暴露到公网**。 - -需要云端测试时,请使用 [阿里云私有部署方案](docs/aliyun-private-deployment.md),通过 SSH 隧道访问,保持 Link、PostgreSQL 与 Crawler 端口关闭。 - -## 版本路线 - -| 阶段 | 重点 | 状态 | -| --- | --- | --- | -| `0.2.x` | Codex 原始数据闭环、LinkAgent、来源浏览、网页采集 | 进行中 | -| 下一阶段 | 可配置的自动同步、Usage 调度器、连接器 SDK 与版本升级 | 规划中 | -| 后续连接器 | 飞书、本地文件与更多本机工具 | 规划中 | -| 数据价值层 | 治理、AI 提炼、个人记忆与检索 | 数据底座稳定后启动 | -| SaaS | 邮箱登录、租户隔离、移动端与正式云部署 | 暂不开发 | - -## 项目结构 - -| 路径 | 内容 | -| --- | --- | -| `src/` | Link Web、平台 API 与 PostgreSQL 数据访问 | -| `link-agent-client/` | Tauri + Rust + React 的 LinkAgent 客户端 | -| `crawler_backend/` | 独立网页采集服务 | -| `docs/` | PRD、技术设计、部署和运行文档 | -| `deploy/aliyun/` | 阿里云 ECS 私有测试部署脚本 | +```bash +.venv/bin/smallink-server --cwd /path/to/workspace --port 42871 +``` -平台开发验证: +启动浏览器开发界面: ```bash -npm ci -npm run lint -npm run build +cd surfaces/gui +npm install +npm run dev ``` -LinkAgent 开发验证: +启动桌面应用: ```bash -cd link-agent-client -npm ci -npm run build -cd src-tauri -cargo test +cd surfaces/gui +npm run tauri dev ``` -## 参与 Link +常用命令: -- 发现问题:使用 [Bug report](https://github.com/Jo-Tsu/link/issues/new?template=bug_report.yml)。 -- 提议连接器或产品能力:使用 [Feature request](https://github.com/Jo-Tsu/link/issues/new?template=feature_request.yml)。 -- 交流用法与产品方向:进入 [GitHub Discussions](https://github.com/Jo-Tsu/link/discussions)。 -- 准备贡献代码:先阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 和 [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)。 -- 报告安全问题:按照 [SECURITY.md](SECURITY.md) 私下联系,不要公开敏感日志。 +- `smallink`:终端交互界面; +- `smallink-server`:本地 API 与 WebSocket 服务; +- `smallink-connectors`:连接器管理工具。 + +## 项目结构 + +| 路径 | 职责 | +| --- | --- | +| `smallink/` | 后端、智能体、任务、工具、连接器与数据能力的正式实现 | +| `surfaces/gui/` | Web 开发界面与 Tauri 桌面客户端 | +| `stt/` | 可复用的本地语音输入模块 | +| `tests/` | 后端测试与产品行为回归 | +| `docs/` | 产品事实源、技术设计与 UI 规范 | +| `packaging/` | macOS / Windows 构建与发布脚本 | +| `link/` | 只读兼容转发层,不承载新业务实现 | -当前尚未启用 CLA,在 CLA 生效前不会合并包含实质代码的外部贡献,但欢迎 Issue、设计讨论、文档建议与可复现的问题报告。 +## 隐私与安全 -## 许可证 +- 默认本地运行,不主动联系占位或继承的云端服务; +- 连接器必须由用户授权,密钥不进入仓库; +- 高风险工具调用进入权限检查与人工确认; +- 原始事实不可被 AI 结论覆盖; +- 智能体不能绕过候选与人工确认直接写入正式记忆。 -Link 采用 [Business Source License 1.1](LICENSE) 进行源代码开放。允许个人、开发测试和内部业务使用,但未经商业授权不得把 Link 的主要功能作为第三方托管或管理服务提供。`0.2.1` 计划在 `2030-07-19` 转为 Apache-2.0。 +## License -BSL 不是 OSI 认可的开源许可证,因此本项目对外使用“源代码开放”或 `source available`,不会用模糊表述隐藏使用边界。 +Smallink 第一方代码使用 [Business Source License 1.1](LICENSE)。继承代码与第三方许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。 diff --git a/SECURITY.md b/SECURITY.md index e9a2731..401292b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,20 +2,18 @@ ## Supported Version -只有最新的开发预览版本接受安全修复。当前 `0.2.x` 仅用于受信任电脑上的本机测试,不支持直接部署到公网。 +只有最新的 Smallink 测试版本接受安全修复。当前产品面向受信任电脑上的本地单用户环境,不支持把本地服务直接暴露到公网。 ## Reporting a Vulnerability -请使用代码托管平台的私密漏洞报告功能,不要在公开 Issue 中提交令牌、数据库内容、Codex 对话、本机路径或可直接利用的漏洞细节。 +请使用代码托管平台的私密漏洞报告功能。不要在公开 Issue 中提交 API Key、访问令牌、对话原文、本机绝对路径、数据库文件或可直接利用的漏洞细节。 -报告应包含受影响版本、复现条件、影响范围和最小复现步骤。维护者确认后会协调修复和披露时间。 +报告应包含受影响版本、复现条件、影响范围和最小复现步骤。维护者确认后会协调修复与披露时间。 ## Current Security Boundary -- Web、PostgreSQL 和 Crawler 默认只绑定 `127.0.0.1`。 -- LinkAgent 只主动连接平台,不开放本机入站端口。 -- Agent 设备接口使用设备令牌;平台管理接口尚未提供公网用户认证。 -- 当前版本没有多租户隔离、RLS、邮箱登录、配额和平台运营权限。 -- 不要把数据库备份、`.env`、DMG、本地队列和 `~/.codex` 内容提交到仓库。 - -当前只支持通过 SSH 隧道访问的单用户私有云测试部署,具体边界见 [阿里云私有部署方案](docs/aliyun-private-deployment.md)。多租户、公网直连和生产 SaaS 不在当前支持范围内。 +- macOS 客户端启动本地 Python sidecar,默认仅监听 `127.0.0.1`。 +- 业务数据保存在用户本机,模型和连接器凭据通过系统 Keychain 管理。 +- 工具执行、文件访问和外部连接必须经过 Smallink 的权限与风险策略。 +- 当前版本是单用户本地产品,不提供公网认证、多租户隔离或生产 SaaS 安全边界。 +- 不要提交 `.env`、数据库、安装包、构建目录、运行日志或真实用户原始数据。 diff --git a/THIRD_PARTY_LICENSES/OpenWorker-MIT.txt b/THIRD_PARTY_LICENSES/OpenWorker-MIT.txt new file mode 100644 index 0000000..60d3824 --- /dev/null +++ b/THIRD_PARTY_LICENSES/OpenWorker-MIT.txt @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2024 Andrew Ng + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..77141fc --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,14 @@ +# Third-Party Notices + +## OpenWorker + +Parts of Smallink are derived from the OpenWorker project: + +- Source: https://github.com/andrewyng/openworker +- License: MIT +- Copyright: Andrew Ng + +Smallink is a product and architecture refactor of that codebase. The original MIT license +text is preserved in `THIRD_PARTY_LICENSES/OpenWorker-MIT.txt`. This notice records source provenance only; OpenWorker is +not a second runtime, package, command, configuration namespace, or user-facing product +inside Smallink. diff --git a/TRADEMARKS.md b/TRADEMARKS.md index f2d845b..e51364b 100644 --- a/TRADEMARKS.md +++ b/TRADEMARKS.md @@ -1,7 +1,7 @@ # Trademarks -源码许可证不授予 Link、LinkAgent、项目 Logo 或其他项目标识的商标使用权。 +源码许可证不授予 Smallink、项目 Logo 或其他项目标识的商标使用权。 -你可以在事实描述中说明软件基于 Link,但修改版或派生产品不得暗示得到官方认可,也不得使用容易与官方产品混淆的名称、图标、安装包标识或服务域名。 +你可以在事实描述中说明软件基于 Smallink,但修改版或派生产品不得暗示得到官方认可,也不得使用容易与官方产品混淆的名称、图标、安装包标识或服务域名。 未经授权的派生版本应使用自己的名称和视觉标识。项目许可证要求保留的版权和许可证通知不受本政策影响。 diff --git a/agent/link-agent.mjs b/agent/link-agent.mjs deleted file mode 100644 index 314c929..0000000 --- a/agent/link-agent.mjs +++ /dev/null @@ -1,458 +0,0 @@ -#!/usr/bin/env node - -import { execFile } from "node:child_process"; -import { generateKeyPairSync, randomUUID } from "node:crypto"; -import { promises as fs } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); -const version = "0.1.0"; -const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const agentHome = process.env.LINK_AGENT_HOME || path.join(os.homedir(), ".link-agent"); -const configPath = path.join(agentHome, "config.json"); -const queueDir = path.join(agentHome, "queue"); -const cursorDir = path.join(agentHome, "cursors"); -const logPath = path.join(agentHome, "logs", "agent.log"); - -function usage() { - console.log(`link-agent ${version} - -Usage: - link-agent init - link-agent connect --code [--name ] - link-agent start [--once] - link-agent status - link-agent sync codex [--limit ] - link-agent logs`); -} - -async function ensureHome() { - await fs.mkdir(queueDir, { recursive: true, mode: 0o700 }); - await fs.mkdir(cursorDir, { recursive: true, mode: 0o700 }); - await fs.mkdir(path.dirname(logPath), { recursive: true, mode: 0o700 }); -} - -async function appendLog(event, detail = {}) { - await ensureHome(); - await fs.appendFile( - logPath, - `${JSON.stringify({ at: new Date().toISOString(), event, ...detail })}\n`, - { - mode: 0o600, - }, - ); -} - -async function readConfig(required = true) { - try { - return JSON.parse(await fs.readFile(configPath, "utf8")); - } catch (error) { - if (!required && error && error.code === "ENOENT") return undefined; - throw new Error("Agent is not initialized. Run: link-agent init"); - } -} - -async function writeConfig(config) { - await ensureHome(); - await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); - await fs.chmod(configPath, 0o600); -} - -async function readCursor(connectorId) { - try { - return JSON.parse(await fs.readFile(path.join(cursorDir, `${connectorId}.json`), "utf8")); - } catch (error) { - if (error && error.code === "ENOENT") return {}; - throw error; - } -} - -async function writeCursor(connectorId, cursor) { - await fs.writeFile( - path.join(cursorDir, `${connectorId}.json`), - `${JSON.stringify(cursor, null, 2)}\n`, - { - mode: 0o600, - }, - ); -} - -function trimPlatformUrl(value) { - return value.replace(/\/$/, ""); -} - -async function request(config, endpoint, body) { - const response = await fetch(`${trimPlatformUrl(config.platformUrl)}${endpoint}`, { - method: "POST", - headers: { - "content-type": "application/json", - ...(config.token ? { authorization: `Bearer ${config.token}` } : {}), - }, - body: JSON.stringify(body), - }); - const result = await response.json().catch(() => ({})); - if (!response.ok || !result.ok) - throw new Error(result.error || `Platform request failed (${response.status})`); - return result; -} - -async function initialize() { - const existing = await readConfig(false); - if (existing) { - console.log(`Agent already initialized: ${agentHome}`); - return; - } - await writeConfig({ - version, - platformUrl: "", - agentId: "", - token: "", - publicKey: "", - displayName: os.hostname(), - createdAt: new Date().toISOString(), - }); - await appendLog("initialized", { agentHome }); - console.log(`Initialized LinkAgent at ${agentHome}`); -} - -function optionValue(args, name) { - const index = args.indexOf(name); - return index >= 0 ? args[index + 1] || "" : ""; -} - -async function connect(args) { - const platformUrl = args[0]; - const code = optionValue(args, "--code"); - const displayName = optionValue(args, "--name") || os.hostname(); - if (!platformUrl || !code) - throw new Error("Usage: link-agent connect --code "); - const config = await readConfig(); - const { publicKey } = generateKeyPairSync("ed25519", { - publicKeyEncoding: { type: "spki", format: "pem" }, - }); - const response = await request({ platformUrl }, "/api/agents/pair", { - code, - publicKey, - displayName, - version, - operatingSystem: `${process.platform}-${process.arch}`, - }); - await writeConfig({ - ...config, - platformUrl: trimPlatformUrl(platformUrl), - agentId: response.agentId, - token: response.token, - publicKey, - displayName: response.displayName, - pairedAt: new Date().toISOString(), - }); - await appendLog("paired", { - agentId: response.agentId, - platformUrl: trimPlatformUrl(platformUrl), - }); - console.log(`Paired as ${response.displayName} (${response.agentId})`); -} - -function compact(value, limit = 280) { - const text = String(value || "") - .replaceAll("\0", "") - .replace(/\s+/g, " ") - .trim(); - return text.length > limit ? `${text.slice(0, limit)}...` : text; -} - -function normalizeCodexSnapshot(snapshot) { - const records = []; - for (const thread of snapshot.threads || []) { - const baseMetadata = { - connector: "codex_local", - generated_at: snapshot.generatedAt, - thread_id: thread.id, - thread_source: thread.source, - archived: thread.archived, - model: thread.model, - }; - (thread.conversation?.inputs || []).forEach((item, index) => - records.push({ - source: "codex", - sourceThreadId: thread.id, - sourceContainerId: thread.id, - sourceRecordId: `${thread.id}:user_input:${index}:${item.at || ""}`, - rawType: "user_input", - rawLabel: "Codex 用户消息", - rawText: item.text, - occurredAt: item.at || thread.updatedAt || thread.startedAt, - projectPath: thread.cwd || "", - threadTitle: thread.title, - sourceUri: thread.file, - summary: compact(item.text), - metadata: { ...baseMetadata, message_index: index }, - }), - ); - (thread.conversation?.outputs || []).forEach((item, index) => - records.push({ - source: "codex", - sourceThreadId: thread.id, - sourceContainerId: thread.id, - sourceRecordId: `${thread.id}:codex_output:${index}:${item.at || ""}`, - rawType: "codex_output", - rawLabel: "Codex 输出", - rawText: item.text, - occurredAt: item.at || thread.updatedAt || thread.startedAt, - projectPath: thread.cwd || "", - threadTitle: thread.title, - sourceUri: thread.file, - summary: compact(item.text), - metadata: { ...baseMetadata, message_index: index }, - }), - ); - (thread.toolSummary?.cliCommands || []).forEach((command, index) => { - const rawText = `${command.title}:${command.cmd};工作目录:${command.workdir || thread.cwd || "未知"};输出摘要:${command.output || "暂无"}`; - records.push({ - source: "codex", - sourceThreadId: thread.id, - sourceContainerId: thread.id, - sourceRecordId: `${thread.id}:cli:${index}:${command.at || ""}`, - rawType: "cli", - rawLabel: "CLI", - rawText, - occurredAt: command.at || thread.updatedAt || thread.startedAt, - projectPath: command.workdir || thread.cwd || "", - threadTitle: thread.title, - sourceUri: thread.file, - summary: compact(`${command.title}:${command.cmd}`), - metadata: { ...baseMetadata, command: command.cmd, command_title: command.title }, - }); - }); - (thread.toolSummary?.tools || []).forEach((tool, index) => { - const rawText = `${tool.name} 工具在该线程中调用 ${tool.count} 次。`; - records.push({ - source: "codex", - sourceThreadId: thread.id, - sourceContainerId: thread.id, - sourceRecordId: `${thread.id}:tool:${index}:${tool.name}`, - rawType: "tool", - rawLabel: "工具调用", - rawText, - occurredAt: thread.updatedAt || thread.startedAt, - projectPath: thread.cwd || "", - threadTitle: thread.title, - sourceUri: thread.file, - summary: rawText, - metadata: { ...baseMetadata, tool_name: tool.name, tool_count: tool.count }, - }); - }); - (thread.ingestion?.skillRefs || []).forEach((skill, index) => { - const rawText = `skill:${skill}`; - records.push({ - source: "codex", - sourceThreadId: thread.id, - sourceContainerId: thread.id, - sourceRecordId: `${thread.id}:skill:${index}:${skill}`, - rawType: "skill", - rawLabel: "技能线索", - rawText, - occurredAt: thread.updatedAt || thread.startedAt, - projectPath: thread.cwd || "", - threadTitle: thread.title, - sourceUri: thread.file, - summary: rawText, - metadata: { ...baseMetadata, skill }, - }); - }); - } - return records.filter((record) => record.rawText && String(record.rawText).trim()); -} - -async function collectCodexRecords() { - const snapshotPath = path.join(agentHome, `codex-snapshot-${randomUUID()}.json`); - try { - await execFileAsync( - process.execPath, - [path.join(rootDir, "scripts", "import-codex-history.mjs")], - { - cwd: rootDir, - timeout: 120_000, - maxBuffer: 8 * 1024 * 1024, - env: { ...process.env, CODEX_HISTORY_OUTPUT: snapshotPath }, - }, - ); - return JSON.parse(await fs.readFile(snapshotPath, "utf8")); - } finally { - await fs.rm(snapshotPath, { force: true }); - } -} - -async function enqueueBatch(batch) { - const file = path.join(queueDir, `${batch.batchId}.json`); - await fs.writeFile(file, `${JSON.stringify(batch)}\n`, { mode: 0o600 }); - return file; -} - -async function flushQueue(config) { - const entries = (await fs.readdir(queueDir)).filter((name) => name.endsWith(".json")).sort(); - let uploaded = 0; - for (const name of entries) { - const file = path.join(queueDir, name); - const batch = JSON.parse(await fs.readFile(file, "utf8")); - const result = await request(config, "/api/agents/ingestion-batches", { - ...batch, - agentId: config.agentId, - }); - await fs.rm(file, { force: true }); - if (batch.cursorAfter) await writeCursor(batch.connectorId, batch.cursorAfter); - uploaded += result.records || 0; - await appendLog("batch_uploaded", { - batchId: batch.batchId, - connectorId: batch.connectorId, - ...result, - }); - } - return uploaded; -} - -async function syncCodex(args = []) { - const config = await readConfig(); - if (!config.agentId || !config.token || !config.platformUrl) - throw new Error("Agent is not paired. Run link-agent connect first."); - const snapshot = await collectCodexRecords(); - const cursorBefore = await readCursor("codex_local"); - const latest = cursorBefore.latestOccurredAt ? Date.parse(cursorBefore.latestOccurredAt) : 0; - let records = normalizeCodexSnapshot(snapshot).filter( - (record) => !latest || Date.parse(record.occurredAt || 0) > latest, - ); - const requestedLimit = Number(optionValue(args, "--limit")); - if (Number.isFinite(requestedLimit) && requestedLimit > 0) - records = records.slice(0, requestedLimit); - const latestOccurredAt = records.reduce((latestAt, record) => { - const current = record.occurredAt || ""; - return current > latestAt ? current : latestAt; - }, cursorBefore.latestOccurredAt || ""); - const cursorAfter = { latestOccurredAt, syncedAt: new Date().toISOString() }; - for (let offset = 0; offset < records.length; offset += 200) { - await enqueueBatch({ - batchId: `codex-${randomUUID()}`, - connectorId: "codex_local", - cursorBefore, - cursorAfter, - records: records.slice(offset, offset + 200), - }); - } - const uploaded = await flushQueue(config); - await appendLog("codex_sync_finished", { queued: records.length, uploaded, cursorAfter }); - console.log(`Codex sync complete: ${uploaded} records uploaded (${records.length} selected).`); - return { records: records.length, uploaded }; -} - -async function heartbeat(config) { - const response = await request(config, "/api/agents/heartbeat", { - agentId: config.agentId, - version, - operatingSystem: `${process.platform}-${process.arch}`, - connectorStates: [{ id: "codex_local", status: "ready" }], - }); - for (const command of response.commands || []) { - let success = true; - let result = {}; - try { - if (command.type === "sync_connector" && command.payload?.connectorId === "codex_local") { - const limit = Number(command.payload?.limit); - result = await syncCodex( - Number.isFinite(limit) && limit > 0 ? ["--limit", String(limit)] : [], - ); - } else if (command.type === "refresh_config") result = { refreshed: true }; - else if (command.type === "validate_connector") - result = { valid: command.payload?.connectorId === "codex_local" }; - else if (command.type === "pause_connector") result = { paused: true }; - else throw new Error("Unsupported command payload"); - } catch (error) { - success = false; - result = { error: error instanceof Error ? error.message : "Command failed" }; - } - await request(config, "/api/agents/commands/complete", { - agentId: config.agentId, - commandId: command.id, - success, - result, - }); - } - return response; -} - -async function start(args) { - const config = await readConfig(); - if (!config.agentId || !config.token || !config.platformUrl) - throw new Error("Agent is not paired. Run link-agent connect first."); - const run = async () => { - try { - await heartbeat(config); - await flushQueue(config); - await appendLog("heartbeat", { status: "ok" }); - } catch (error) { - await appendLog("heartbeat", { - status: "error", - error: error instanceof Error ? error.message : "Unknown error", - }); - console.error(error instanceof Error ? error.message : error); - } - }; - await run(); - if (args.includes("--once")) return; - console.log("LinkAgent started. Press Ctrl+C to stop."); - setInterval(() => void run(), 30_000); -} - -async function status() { - const config = await readConfig(false); - const queued = (await fs.readdir(queueDir).catch(() => [])).filter((name) => - name.endsWith(".json"), - ).length; - console.log( - JSON.stringify( - { - initialized: Boolean(config), - agentHome, - agentId: config?.agentId || "", - platformUrl: config?.platformUrl || "", - queuedBatches: queued, - version, - }, - null, - 2, - ), - ); -} - -async function logs() { - try { - const lines = (await fs.readFile(logPath, "utf8")).trim().split("\n").slice(-80); - console.log(lines.join("\n")); - } catch (error) { - if (error && error.code === "ENOENT") return; - throw error; - } -} - -async function main() { - const [command, ...args] = process.argv.slice(2); - if (!command || ["help", "--help", "-h"].includes(command)) return usage(); - if (command === "init") return initialize(); - if (command === "connect") return connect(args); - if (command === "start") return start(args); - if (command === "status") return status(); - if (command === "sync" && args[0] === "codex") return syncCodex(args.slice(1)); - if (command === "logs") return logs(); - usage(); - process.exitCode = 1; -} - -main().catch(async (error) => { - await appendLog("fatal", { - error: error instanceof Error ? error.message : "Unknown error", - }).catch(() => undefined); - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; -}); diff --git a/components.json b/components.json deleted file mode 100644 index f0817a8..0000000 --- a/components.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { - "css": "src/styles.css", - "baseColor": "slate", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "rtl": false, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "registries": {} -} diff --git a/crawler_backend/app.py b/crawler_backend/app.py deleted file mode 100644 index b85e3af..0000000 --- a/crawler_backend/app.py +++ /dev/null @@ -1,229 +0,0 @@ -from __future__ import annotations - -import ipaddress -import json -import os -import socket -import threading -import time -import traceback -import uuid -from concurrent.futures import ThreadPoolExecutor -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path -from urllib.parse import urlparse - -from scrapling.fetchers import DynamicFetcher, Fetcher, StealthyFetcher - - -HOST = "0.0.0.0" -PORT = 8000 -MAX_HTML = 2_000_000 -MAX_TEXT = 500_000 -JOBS_FILE = Path(os.environ.get("CRAWLER_JOBS_FILE", "/data/jobs.json")) - - -def load_jobs() -> dict[str, dict]: - try: - records = json.loads(JOBS_FILE.read_text("utf-8")) - jobs = {str(item["id"]): item for item in records if isinstance(item, dict) and item.get("id")} - for job in jobs.values(): - if job.get("status") in {"queued", "running"}: - job.update(status="failed", error="采集服务重启,任务未完成", result=None, finished_at=time.time()) - return jobs - except FileNotFoundError: - return {} - except Exception: - traceback.print_exc() - return {} - - -JOBS: dict[str, dict] = load_jobs() -JOBS_LOCK = threading.Lock() -EXECUTOR = ThreadPoolExecutor(max_workers=2, thread_name_prefix="crawl") - - -def persist_jobs_locked() -> None: - JOBS_FILE.parent.mkdir(parents=True, exist_ok=True) - temporary = JOBS_FILE.with_suffix(".tmp") - temporary.write_text(json.dumps(list(JOBS.values()), ensure_ascii=False), "utf-8") - temporary.replace(JOBS_FILE) - - -def public_url(value: str) -> str: - parsed = urlparse(value) - if parsed.scheme not in {"http", "https"} or not parsed.hostname: - raise ValueError("仅支持有效的 HTTP/HTTPS 网址") - if parsed.username or parsed.password: - raise ValueError("网址不能包含用户名或密码") - try: - addresses = {item[4][0] for item in socket.getaddrinfo(parsed.hostname, parsed.port or 443)} - except socket.gaierror as exc: - raise ValueError("目标域名无法解析") from exc - for address in addresses: - ip = ipaddress.ip_address(address) - if not ip.is_global: - raise ValueError("为保护内网安全,不能采集本机或内网地址") - return value - - -def response_text(response) -> str: - try: - return response.get_all_text(separator="\n", strip=True) - except TypeError: - return response.get_all_text() - - -def extract_fields(response, fields: list[dict]) -> dict: - extracted = {} - for field in fields: - name = str(field.get("name", "")).strip() - selector = str(field.get("selector", "")).strip() - if not name or not selector: - continue - nodes = response.xpath(selector) if field.get("selector_type") == "xpath" else response.css(selector) - if field.get("multiple"): - values = nodes.getall() - extracted[name] = [str(value).strip() for value in values if str(value).strip()] - else: - value = nodes.get() - extracted[name] = str(value).strip() if value is not None else None - return extracted - - -def run_job(job_id: str, payload: dict) -> None: - with JOBS_LOCK: - JOBS[job_id]["status"] = "running" - persist_jobs_locked() - try: - url = public_url(str(payload.get("url", ""))) - mode = payload.get("mode", "static") - timeout_seconds = max(1, min(60, int(payload.get("timeout_ms", 30000))) / 1000) - selector_config = { - "adaptive": bool(payload.get("adaptive", False)), - "huge_tree": bool(payload.get("huge_tree", False)), - } - if mode == "static": - response = Fetcher.get(url, timeout=timeout_seconds, selector_config=selector_config) - else: - options = { - "timeout": timeout_seconds * 1000, - "wait": max(0, min(10000, int(payload.get("wait_ms", 0)))), - "wait_selector": payload.get("wait_selector") or None, - "network_idle": bool(payload.get("network_idle", True)), - "disable_resources": bool(payload.get("disable_resources", False)), - "block_ads": bool(payload.get("block_ads", True)), - "selector_config": selector_config, - "headless": True, - } - response = ( - StealthyFetcher.fetch(url, **options) - if mode == "stealth" - else DynamicFetcher.fetch(url, **options) - ) - - html = response.html_content - if isinstance(html, bytes): - html = html.decode(response.encoding or "utf-8", errors="replace") - text = response_text(response) - title = response.css("title::text").get() or url - truncated = len(html) > MAX_HTML or len(text) > MAX_TEXT - result = { - "title": str(title).strip(), - "status_code": int(response.status), - "text": text[:MAX_TEXT], - "html": html[:MAX_HTML], - "fields": extract_fields(response, payload.get("fields") or []), - "truncated": truncated, - } - with JOBS_LOCK: - JOBS[job_id].update(status="succeeded", result=result, error=None, finished_at=time.time()) - persist_jobs_locked() - except Exception as exc: - traceback.print_exc() - with JOBS_LOCK: - JOBS[job_id].update(status="failed", error=str(exc), result=None, finished_at=time.time()) - persist_jobs_locked() - - -class Handler(BaseHTTPRequestHandler): - server_version = "LinkCrawler/1.0" - - def end_headers(self) -> None: - self.send_header("Access-Control-Allow-Origin", "*") - self.send_header("Access-Control-Allow-Headers", "Content-Type") - self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - super().end_headers() - - def json_response(self, status: int, payload: dict) -> None: - body = json.dumps(payload, ensure_ascii=False).encode("utf-8") - self.send_response(status) - self.send_header("Content-Type", "application/json; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def do_OPTIONS(self) -> None: - self.send_response(204) - self.end_headers() - - def do_GET(self) -> None: - if self.path == "/api/health": - self.json_response(200, {"ok": True, "service": "link-crawler", "engine": "Scrapling"}) - return - if self.path == "/api/crawls": - with JOBS_LOCK: - jobs = sorted(JOBS.values(), key=lambda item: item["created_at"], reverse=True) - self.json_response(200, {"jobs": jobs}) - return - prefix = "/api/crawls/" - if self.path.startswith(prefix): - job_id = self.path[len(prefix) :].split("?", 1)[0] - with JOBS_LOCK: - job = JOBS.get(job_id) - if job: - self.json_response(200, job) - else: - self.json_response(404, {"detail": "任务不存在"}) - return - self.json_response(404, {"detail": "Not found"}) - - def do_POST(self) -> None: - if self.path != "/api/crawls": - self.json_response(404, {"detail": "Not found"}) - return - try: - length = int(self.headers.get("Content-Length", "0")) - payload = json.loads(self.rfile.read(length) or b"{}") - url = public_url(str(payload.get("url", ""))) - mode = payload.get("mode", "static") - if mode not in {"static", "dynamic", "stealth"}: - raise ValueError("未知的抓取模式") - job_id = str(uuid.uuid4()) - job = { - "id": job_id, - "url": url, - "mode": mode, - "status": "queued", - "created_at": time.time(), - "error": None, - "result": None, - } - with JOBS_LOCK: - JOBS[job_id] = job - persist_jobs_locked() - EXECUTOR.submit(run_job, job_id, payload) - self.json_response(202, {"id": job_id, "status": "queued"}) - except (ValueError, json.JSONDecodeError) as exc: - self.json_response(400, {"detail": str(exc)}) - except Exception as exc: - traceback.print_exc() - self.json_response(500, {"detail": str(exc)}) - - def log_message(self, fmt: str, *args) -> None: - print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {self.address_string()} {fmt % args}", flush=True) - - -if __name__ == "__main__": - print(f"Link crawler API listening on {HOST}:{PORT}", flush=True) - ThreadingHTTPServer((HOST, PORT), Handler).serve_forever() diff --git a/db/init/001_schema.sql b/db/init/001_schema.sql deleted file mode 100644 index 793a85e..0000000 --- a/db/init/001_schema.sql +++ /dev/null @@ -1,117 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS pgcrypto; - -CREATE TABLE IF NOT EXISTS sensory_records ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - source TEXT NOT NULL, - source_thread_id TEXT, - source_account_id TEXT, - source_container_id TEXT, - source_record_id TEXT, - source_record_hash TEXT, - raw_type TEXT NOT NULL, - raw_label TEXT, - raw_text TEXT NOT NULL, - normalized_text TEXT, - occurred_at TIMESTAMPTZ, - synced_at TIMESTAMPTZ NOT NULL DEFAULT now(), - project_path TEXT, - thread_title TEXT, - source_uri TEXT, - sensitivity_level TEXT NOT NULL DEFAULT 'normal', - noise_level TEXT NOT NULL DEFAULT 'normal', - metadata JSONB NOT NULL DEFAULT '{}'::jsonb, - status TEXT NOT NULL DEFAULT 'ingested', - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_sensory_records_source_hash - ON sensory_records (source, source_record_hash) - WHERE source_record_hash IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_sensory_records_source_synced_at - ON sensory_records (source, synced_at DESC); - -CREATE INDEX IF NOT EXISTS idx_sensory_records_container_occurred_at - ON sensory_records (source_container_id, occurred_at DESC); - -CREATE TABLE IF NOT EXISTS connector_sync_runs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - connector_id TEXT NOT NULL, - source TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'completed', - total_records INTEGER NOT NULL DEFAULT 0, - inserted_records INTEGER NOT NULL DEFAULT 0, - updated_records INTEGER NOT NULL DEFAULT 0, - error_message TEXT NOT NULL DEFAULT '', - started_at TIMESTAMPTZ NOT NULL DEFAULT now(), - completed_at TIMESTAMPTZ, - metadata JSONB NOT NULL DEFAULT '{}'::jsonb -); - -CREATE INDEX IF NOT EXISTS idx_connector_sync_runs_source_completed_at - ON connector_sync_runs (source, completed_at DESC); - -CREATE TABLE IF NOT EXISTS runtime_configs ( - id TEXT PRIMARY KEY, - category TEXT NOT NULL CHECK (category IN ('connector', 'model', 'agent')), - name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - enabled BOOLEAN NOT NULL DEFAULT false, - status TEXT NOT NULL DEFAULT 'not_configured', - config JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS link_agents ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - display_name TEXT NOT NULL, - public_key TEXT NOT NULL DEFAULT '', - token_hash TEXT NOT NULL UNIQUE, - status TEXT NOT NULL DEFAULT 'online', - version TEXT NOT NULL DEFAULT '', - operating_system TEXT NOT NULL DEFAULT '', - connector_states JSONB NOT NULL DEFAULT '[]'::jsonb, - last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - revoked_at TIMESTAMPTZ -); - -CREATE TABLE IF NOT EXISTS agent_pairing_codes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code_hash TEXT NOT NULL UNIQUE, - expires_at TIMESTAMPTZ NOT NULL, - used_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS agent_commands ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - agent_id UUID NOT NULL REFERENCES link_agents(id) ON DELETE CASCADE, - command_type TEXT NOT NULL CHECK (command_type IN ('sync_connector', 'validate_connector', 'refresh_config', 'pause_connector')), - payload JSONB NOT NULL DEFAULT '{}'::jsonb, - status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'delivered', 'completed', 'failed', 'cancelled')), - expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - delivered_at TIMESTAMPTZ, - completed_at TIMESTAMPTZ, - result JSONB NOT NULL DEFAULT '{}'::jsonb -); - -CREATE TABLE IF NOT EXISTS agent_ingestion_batches ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - agent_id UUID NOT NULL REFERENCES link_agents(id) ON DELETE CASCADE, - batch_id TEXT NOT NULL, - connector_id TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'completed', - record_count INTEGER NOT NULL DEFAULT 0, - inserted_records INTEGER NOT NULL DEFAULT 0, - updated_records INTEGER NOT NULL DEFAULT 0, - cursor_before JSONB NOT NULL DEFAULT '{}'::jsonb, - cursor_after JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (agent_id, batch_id) -); - -CREATE INDEX IF NOT EXISTS idx_link_agents_last_seen ON link_agents (last_seen_at DESC); -CREATE INDEX IF NOT EXISTS idx_agent_commands_pending ON agent_commands (agent_id, status, expires_at); diff --git a/deploy/aliyun/bootstrap.sh b/deploy/aliyun/bootstrap.sh deleted file mode 100755 index b062730..0000000 --- a/deploy/aliyun/bootstrap.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPOSITORY_URL="${LINK_REPOSITORY_URL:-https://github.com/Jo-Tsu/link.git}" -INSTALL_DIR="${LINK_INSTALL_DIR:-$HOME/link}" - -if ! command -v sudo >/dev/null 2>&1; then - echo "sudo is required on the ECS host." >&2 - exit 1 -fi - -if ! command -v apt-get >/dev/null 2>&1; then - echo "This bootstrap currently supports Ubuntu 24.04/22.04. Use an Ubuntu ECS image." >&2 - exit 1 -fi - -sudo apt-get update -sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \ - ca-certificates \ - curl \ - docker.io \ - docker-compose-v2 \ - git \ - openssl -sudo systemctl enable --now docker - -if [[ -d "$INSTALL_DIR/.git" ]]; then - git -C "$INSTALL_DIR" fetch origin main - git -C "$INSTALL_DIR" checkout main - git -C "$INSTALL_DIR" pull --ff-only origin main -else - git clone --branch main --single-branch "$REPOSITORY_URL" "$INSTALL_DIR" -fi - -cd "$INSTALL_DIR" - -if [[ ! -f .env ]]; then - postgres_password="$(openssl rand -hex 32)" - cat >.env </dev/null; then - echo "Link is healthy on the ECS loopback interface." - echo "Keep ports 41737, 55437 and 18744 closed in the security group." - exit 0 - fi - sleep 2 -done - -sudo docker compose ps -sudo docker compose logs --tail=120 link-app postgres -echo "Link did not become healthy in time." >&2 -exit 1 diff --git a/deploy/aliyun/deploy-existing-ecs.sh b/deploy/aliyun/deploy-existing-ecs.sh deleted file mode 100755 index 28e918a..0000000 --- a/deploy/aliyun/deploy-existing-ecs.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ $# -lt 1 || $# -gt 2 ]]; then - echo "Usage: $0 [private-key-path]" >&2 - exit 1 -fi - -target="$1" -key_path="${2:-}" -ssh_options=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new) - -if [[ -n "$key_path" ]]; then - ssh_options+=(-i "$key_path") -fi - -ssh "${ssh_options[@]}" "$target" "bash -s" <"$(dirname "$0")/bootstrap.sh" diff --git a/deploy/aliyun/open-tunnel.sh b/deploy/aliyun/open-tunnel.sh deleted file mode 100755 index 604cc16..0000000 --- a/deploy/aliyun/open-tunnel.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ $# -lt 1 || $# -gt 2 ]]; then - echo "Usage: $0 [private-key-path]" >&2 - exit 1 -fi - -target="$1" -key_path="${2:-}" -local_port="${LINK_TUNNEL_PORT:-42737}" -remote_port="${LINK_REMOTE_PORT:-41737}" -ssh_options=( - -N - -T - -o ExitOnForwardFailure=yes - -o ServerAliveInterval=30 - -o ServerAliveCountMax=3 - -o StrictHostKeyChecking=accept-new - -L "127.0.0.1:${local_port}:127.0.0.1:${remote_port}" -) - -if [[ -n "$key_path" ]]; then - ssh_options+=(-i "$key_path") -fi - -echo "Opening Link at http://127.0.0.1:${local_port}" -echo "Keep this terminal running while the browser or LinkAgent uses the cloud platform." -exec ssh "${ssh_options[@]}" "$target" diff --git a/design/brand/README.md b/design/brand/README.md new file mode 100644 index 0000000..fd1b3ab --- /dev/null +++ b/design/brand/README.md @@ -0,0 +1,20 @@ +# Smallink 品牌资产 + +## 文件 + +- `smallink-wordmark-source.png`:用户确认的完整 Smallink 字标原稿,仅作为品牌源文件。 +- `smallink-mark.png`:从字标左侧提取的透明背景独立图形,尺寸为 1024 x 1024。 + +## 使用规则 + +- macOS App、Dock、安装包、菜单栏和产品内小尺寸 Logo 只使用 `smallink-mark.png`。 +- 产品名称统一显示为 `Smallink`,不要把完整字标缩进小图标容器。 +- macOS DMG 安装背景标题固定为 `Install Smallink`,副标题固定为 `Drag the app onto the Applications folder`;1x、2x 与 TIFF 版本必须同步更新。 +- 图形周围保留透明留白,不增加底板、阴影、描边或额外文字。 +- 产品视觉统一使用深邃夜蓝 `#00224D`、科技蓝 `#00529B` 和节点青蓝 `#00A8E8`。 +- 完整 UI 规则见 `docs/smallink-ui-design-system.md`。 + +## 兼容边界 + +本次更新只改变对外品牌和视觉资产。现有 `link` Python 包、`X-Link-Token`、 +`~/.config/link/`、`link.db` 与 `.link/` 属于运行兼容标识,未经数据迁移方案不得直接改名。新建会话产物默认使用 `~/Smallink/`,已有用户设置的 `~/Link/` 路径继续有效。 diff --git a/design/brand/smallink-mark.png b/design/brand/smallink-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..61ca2d09b0b4570e6c01b2ab78eee7516c588763 GIT binary patch literal 252781 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%0As#%L5 zNy6qDP-HV`sk*5{75fA;ndJx|Onbxv#E0!z4h`zcsl~Z1k+;uHW+otSi?D(O8u`R^ z&TXAu+i&+YVZ*o4UY*2+pO;Dd|uU^CHu4L}|@TpFLJ$CCfTy!VvPh z_Vvfdch^a-l=5rZ`+Pkf;slz9skMGcRWRPGwsa`zRqFd`qU8%UUKn_vCf#9lM7F-7 zHlLN&))bH?)`0>09#WVi0!JWccE!hxE|ZeWg(7y^>zDTkc=`DevkNImhXTY!4AIif zdB6egNM3}_c5`nFWXNLY`aC@GSIvt!o9vN$_sKu;#wLpsN3&ZV+yTR_?OwH?Gwm?+ z$*&&OEwLjw>YJJq=SswE?=Djl{c6^MC3-lbFYNbCBU|4;i>ci-S5T=QE~uWzSPfU|>{F*DPt&&73c3)b>l>d^rhNc^*=VZC@0SS>h+>V-$Ze4p!?=T^t65 zH*b{(X5}kA=Z}w$mw}63;ska0E=~TjnT`^@$Ku;ltf}0vbE%m9a=@3aH?PljAQOobiJ*6(dZ(G965lJ7 zY`IpB8Q6JD)`MpMg`~04E^SPwbXa$sMb0Y}AI*(Eidb_O>C!T}jmpN)|EsQHHDSyY zZPfuh*kstH>#~)%GW^{x@aWu134y&a1ix+Wi)rLKP+f9hIH$;wl+EkZsPbY&wH)<5 z050+SF}1Mr&Ud4uMcL|Y0wp!-LM|J`W5k2BCb@X#o^J&H>jZ%zK{&lO<^AjM%&YKB z+-=1abVNs1QPs>{T}F^-U|g&pzwnGyssQb}cjXy5ggX}9h30x21 zW=(B=tHgPd<0svNvx&Qw!EI6mQi^2`g!T^rwkmB#m*^F6sv5ys+Ahh)K6Hm<;6j`c zu)o`=Z{aaVR;m8!bYTBacD5o+P@V;Q<8Re5Gf|K_m7owWjOM4UgxtU&C2uo@rq*-%n+*xkWW2CEW_KxP`+#n@79>Z3{loUrggc1KQ1ERjl_pbAb3+@ig<*K_rU9Wr! z1*PZo_jP9G$2p2u>z`r_G{4fN%8H|qau|ryk@^MxXXx((NKg6YmhV_z2a+QNlC2)) zZ|LJ$r}Dj!ty=g=3kPZI5KX=Fo1wO{SzEWOkoM(#(gF_;=Q{bPhdA{10aPJHs%M9_ zIfU5NOoW(>nbc_?0B;kjBcJoD-$e#G%;J&?^zfs&*6xDUlH3{ z$WTe0)bU{|Al1-zW9uS)Io=t_i|)8{$#pJr=wFB7EBhB7aR+)b7b@a&MvZ@^XY=OP zO&c-&U)Dn_1Np;bt1cSB=$+zj6?O!UObK%2W(9;p5(nsNj<;4k4adDqe3LS*#MeBZ zdh1;FsBt&fr)RjOvl>6+zx+E?Dra*YwUFp)>ZzfrI>$Em{Er;LZ{pqZd zWQ2CE@b>hl!BK+$YB~;mLER3Y_TR0*aWs{H*>u2`;Z&whWQ_lA2$h`r@QEd0nstLW3i>u=YHmgf(R$h9|6X`matDkf{vzi-rot#AK*gTQ;KXGbETh#L z(uhn^uX#M|;JM%N(!=XH8?mCNc_0t4Io$f?r-PpIfDZ~FKr{C}+NZk;{cGb&sde`q zevf_UPUTD^X>gyUk1GpbD#wzbngi1Y-XP*pYUHa5q{x_CzZqvb&QWcr9+f2Nzi&PF z)l~V!8^^0?19I5EoC_{lx`UniBoAszhMf)^SV>;+JbQkeQ5%ZN!b{U;NwK;lv|EiA*Te|6g-IxjX^UMH;C(ZWJGn-A3dM zmI*(3Yg?|9&2#qI{5pyJW+T+UE1unAIcE3mCG6>s3z?6|66ZLpIG3{mv?EL2Gl5Ur zOHel*weP62=EZ}e(hNHm3O;(R#Z|=9(EW#~hsx2`lJ9L5b~%Irne)F>)}a{a)@G2e zvCJK<$QjMvO8};JSy1TDKG) z{>OQ2v)?VxWN@8hI=u3m4`_<3}Ra_))h^rY#Ah{mz zVUSi|pbNX=5V`J|8E$`2E=s8FTqJtC2)2%%HY7-Bg`6mSKVU~*jgyPs;Xd0T{ZQk?8^{VLR(|kJc?ksNjWn zi;99#(|xcsdAe%wr0hv_XJKc%Ek~!$AOE`)(;cOd9%~cUw-^O>+nJY)U1a0kv;OP2 z0q+I70;v0;8>gI3f*zy2;L7y-?HAbrxpok)>S5ZuhJ!nvvi{`zTaYeUkzwlh?GO!+ zmbgeTOdDiy9~aqp`5<-NR-`2~Y%ge*Hs~@qFsCwVitUJcgF>P zm1eb%S-mGsgP$z%58rMUtV2(2oxGG%q;8NOMR=z^Y)&(BXX^?;dbP3!YQo^J1;Z!X z)O$SDq3Q#M@*wqOoY$7pK}Q2Fj|#RrEZUSwKvzydcNU(@)CMvpi?sYGm&Dq|^W6#Q%x~2b-C%vKbUal7n7< z0F_jf_Ezwk%?ATZM7@o(B^#0@8&A{nBoF;y7m@!?M8aBw(>gHftGFDV;~>ZeIi|4& zs5Jy&cuuIHtsOz@hzZ16E~QU*LkR9%ukefo2Zwd7^>6?qPAJn3aWZab$wqOz&*FCW z2OZ+8^7_pQ|B|Z)hbRY=o=7J?6A`-n%KwaOcsFRyi@<#b`)em*X>Q~CvnKV~6T}QU zXubx&Bt(&q-ZPxdM|*X8>!>X;s|_i@SHyr9b|XYz*e@jka+4fM(C&s^|k{Z|IMpjFgl+2CA+ptndP& z5l?KdKj?=B-5R6ZfHIBm?*R-eve)C~x74Z3oD3TkGFw1+Arj_G|{LvO8)8utv@loJG&IhS-{i69qhFcChV*A(>IEZd80oj`i*;oj2(oR+S#MOZ}k;H%#5-D;IU3w6BIg%RD1*-h}tEd5nl6h2~?=~e07 zCeY)~tAeV0bS12Y(n6x7Fb2xrnQ-B!xtu<&PIN1xAFgpCXPVh~A zb2_Kk2a8bInzqujkuQ9}o;)#$Zh%-nvn#&7tHvyWn)*C(A4PoILQ}?IC^6 zqQ&KlJ<0>JkrSKujs@G!znaL;$|G#aj*87qElKjS-rWvU4Zt2PGZFFxV)qa~D^;?& z0sw!A*$q%uhKPyO9;>S3-03fSKB=YC_Rd+t-%xY z(wCL6o0UY7@kGyHnkN5+#mxEa+l8?XQjVLDkylP|RBQdX4Aswhmxu9K?}%*yl!KFc zqwA?^>babCxa=!@%Q-4@?sg!n(f-&76ToQd-8}NYujkH{aT=6k;ngXooMNw+B(OG zZ*9|?$U#Z&|LKD3sBzV!P((`~&y6z_vjqPYQ#J#>IARgeZBYV7WUNiH=1F=tS}(cU z+LoSrj`&rK+!tmj&{TVHTNo(o783KAvLyBczI%4<&f$fBr;R@OJ~bTl5IV1MKe!QR zFCp7a{)yd(lX$hcevOOcbBa;OAiSaF~CmwkGs>4R|(-G%9}!!U)ZERdUiEnw4ktU-Bw#Lndfym!ItIUMq?523nD zmZQFYLz~v>i@kqPmW!*(abeDor2h}bB>VnQ_24KT{J)%w<9NG`i8Z^Gqtl0}tdb4) zBu!)l=2&K_#r&FG4YNPqY0F|Y%+(HC4R#22O=o8zv?Q;*>=;!6lYNeFe-xyGX)9{+ z#%XlZJk9?O@H#7pNHzRErStgZkL@l!+r+edsjhxI)mkk4>Xm-RfEo9$Dv+WgPQqgr z1vgq$DA0*o98Vij+ue*rftQ>-IX@jfMPmI2pdZULp(0TZFG%XYui>PO4eWTv!FP8% zu*o`$lNmcd1baxjufgcU0VdhbxthGRi~|JgBf7(tS$zM2rivC)C}jr`j&V(mu%e+i zo5$#2bORR`3r-Kew{^*9Y>oE4#WdnvLM7_;fN{eA*fxMh7 zR6K~8_%sJgMGAhUv&vZ!h{LRC?8Whdod`?j>r~UwhfbN3vx+C+rG`kj(uAID+eEM^ zMOBD@TbBKvKUh~<28Eaz;9Xf#uU+}^)@3gHZ(mu9pNF>6BBTTQb=j#Eb}Q;_Jg^b2~s9Oj*H6e@7K1Z;+^_Sh2G9Kj-T@f*|yXWzc;3(Wi#e!2j2&&)Hd z)C>S^@(@8X3uxF;QQZ;JLYeAl~SH`)_3Ur^pOkW7=eAT~S+NFLqxHipQK>gS& zcV=;s^(wQ082zV*ZXNJ?z9UZQXSUX}SssF#E7D-$$#BoswCz6$P2+fr zX{VlNsS>F?{XWfni859FY_@A8GhxrQe*Qt9P5Ecs>gQ@{ua7v}YqHOjPd(r1D-SGX zr8)fOfa#U|t?m0g)@?Kvvmua-9dOURuqp6u>-Q;bP z9yI$_tBJ<*l;_oMo>+O0Z%EGUrow9HYn$7I$fe1%WBF|=; zLyD^-ti0P~U*j3_OpUX3e#kj03H0w4Har5K^C$Jy`n=*fl7i&K?DP2V_ood(mt1!H zRS%BYFisCC?pzjZ`wz_|B@80%4@kY)VcWQj>iz=mi!nsLRCrLq$+3`OTXz_qF&=~H zoxqt}a>EU+VLDYcG5gUQ7TiN+QvdvfO+*S5Eou2Gzog{;5Wt>EPp7$$wmn1&bG3B* zSy&?y)b+qe-z6i6PIJ7<%5}eqBPW0;(|5ds1HB7LODHa!9(4eHKy%bW~YdmvEUyR!ar;c-ZCxeJR+Dlh3sD&Hb7mi1*N%??O+*0ux zY}$6{bqn5LW)8AruFmoHi#Ou@x9~{ea;o_!3hQbJZfLiKL(_ZwivhsceD!bj!z!x1 z0-!bGmmt^BA-))<<~42WbfR^bvwGTo88q`(5D+bbQy{%x)in3bfU|}WyKWt)2W|Gd zhkJeK{i&#ZnOH%lap^LE2LDHJWVy3>DCrzenwfTr%n1x&%6EGSa(MiC?UCA!uo?4ugnE9uF7jH0Oe>LJ{BMB)1f za{9}H$Nx>*LrSE`q2*lyd;rN!Fx=x*-YAQ*>JPgFVHVZ5%JzP zZ{hr2sfiYM-AFxW17|~G26L)Q1{){c*+N94gR?ZS7C+PcsR>@j<+&^0H1$yU%YwV_ z5qk5FB<1JDg4e%sWO19stB(gl%?Khqv>uZh$6je*5`GSH|A!?p|R z0qJ%%vY4{LtcgA74}%tmrf!=nY@lsNZS;}Dy#?HNMT@3&Feape8pj&c5-$7GJH|Ol zM4|GHPQ!c`VqVyivQbR=BCmf8borr^jI{CX&;P4Weg_7G%W^Th8Hb$8H}i+6uv7b=1L}D`FZSs%wG3$U zgm}9z%J|!{(S77rfBTq|u#wC^p8KrubEG39#i(^ev_swsxVN$O?jmIreS@u>Ocmf& zOxBN@iYYtVeaq}jk{6`A~C(LK%ha2yk3{=xk03~t5V1rBV2cqTkH`LKyGHH1 z9bwU;fI0v9;O&({>;h?y0bz+4S#74yhwNm*GM9_j8b#F&oUhZDmNL`9tiM~-y$^E= zn7)YU&sb&x%3FLnk|PyFc0E_S%5}%%zwA{JuGp$auJ?l{ow_R8DeWi2UlypPTiYxA zT$CrFsoZbRxL(J912KD+Ag{B2;9DGV-&wbjE-#)$cGTXDoDP3BH1|I8e4PkKtWl+V zIvFF{bIomaxryT6f1dox6>z#edzi_;-X901>fZUVLkjNXQUZ2sCJk& z&RyRa@Jp;wXr|_I=jCYd%1P9|92tl%A#CM8YXn*xx?Fqj0&@dSQomjl3%iKn2s^!a z%+1|7GI7m3_AAHIOd%0ZXgMn~-a>wdI2p*+M|?7}^ZW)@e0VroxICNc^MgQ>+?g-j zT#fBm{SfEMNKR956QD0%k!U!yKJx}jwf!88B-m7P$&)PR=9`Ew(tI#*}4Sl2s<*IK#- z#yf;7ASj@$TKS3YiTOvOCJ==K{pszBmgm{YUfQg}?gsed$Xzx)yybkd6zxsEBiC0_ ztJ>IYMzZNjdml5%&3451IntP+hk=P&Rzid@_|4KDLd~*8*@w3I&B*MT@vHNx-L-VJ z8G{#p8cIb=;G{vYsxrdhDLJ`-o&G5O9#;C>i@Doq=$gW^)yOMou!k9FnTme8G36LJTdv-s4xj$vva$~-1H<5 z4CH|Qf@5@f5I>J6XB2^#l;}(M55qa%D>y#*CjFApqU{sUL@`^MWsnG1=u8uz2Z%uG z?6s3@Ue^Wf!^Iz*@3)nIfYGe?fa%_HJivNeo(SXfl7R<5U9R#m=MIy!W4JaNZkIK^ z$aS}U;AYMG6`H#V`*mP}z6#l`1p`)&1JpLh z4}b0G6GIYR0<&BKh;Fv+UKGQ};}LJucwi?9b|JX?2TALf+a2NNG0FUP##d{1ZkJ(D z5Ihod5u@Hd+M#|S#I1VCoSFuCceyH7Xf|VUp>`)U){zt(=VE+k%!82vtViyGChdrr zxjRx%Pd-hp*DR1c3aQ<};3_A&nh5O`Gu;Jt=dc#qa-T7F1KnG17ukUnHdX3H%a%8j z?MkoCmYn3r#*KF4b;lxR#k%5H|EL%;%?jBJ>!c}D+sl*mblz>-^X&XYSQ4Z>o<+`< zR}eAwXlCr2SO5=kw8hPpT8cpdROP`cjAqi4YzCQPT=r4g)4%onxM@6Kaj=%-pSL-p z?n&}~gqExyqFuAweBN!_G&dc1=$ZMm;I;+(l~m^+JFm~}mdMFTvSFN=WJ7QNBjEpb z0e%@Y-ZcN|YDbNRfRwoT0^LJty^3qi-j_3x1nAw)XooM7yv%A@{w#4ED$rS1fBkK? z)dT{ZVWvm?Usb+nfYI(fXcEBSmti|9j&*QzRAdR`knWeh3C9o2Ly1Lx)b| z_w26JB;hBjo##_L#|4OEg3!?Tfcu(Ok27Ei$lxzyr7&FWT4T=nZ7OO0M-cXwPP-si zIV46m=ve4=Uo8LLu{ut&h2mI@lM50vnN{0p5%KZy89$Y)X4gZ9FQFMh3(ct~q7gF8os!uy-MK7)j9apYh z8qs0fB{>?xpyc*r&bkDUvDbbR}kH_5=+j52zRz=xic%$0ogMc(L074%hCc@1QVEqV;K;b zMjallI=i{2E6WWm_E|eoeZNXvVS=AUT`M!gNWzAtE6rv^`RxS%_fq9xBi1|b1ir=r zTo)3~x`DzKl0FLRA7UerN(>o!LDg37=CvTzsma|cgHh`HjCb$GMrDw*)mi@~S|PiN zBBsfuC#DLKDZSF0F3=>1eptst93v$~G@Sh)m-Ly|e!a8T8yd}q_(JZ$AW%>MXvWWb zYSAxI5fv7p$dH!-Moat%n0W@}F$Q5=b#w|XDt01}m>WD29H=mkUQ!v40=|1@rwo%Gjn7o3L=#`4;s@^MLOz4niG^VWr5akjV#$5{|wNR{m@ z+~J%EB-&G4F4zAg3(cinov%M`WhmjwZ65hqDs(EtrFejT*%FicX3{47k{*Q-xSD?iMq5#xL_{8Hog59a`1*( zh?kh&4pZ-z;LP;nnZJZ5F2u%nMwplG$Fn{vTN06a-A6Ys7mpUtR)%tn7EehIc=b@%2JX<~n&s^{zH~zU2~2 zNhd{9u*7Iu?=jFbEn*6sHDGR~ftWNn;Zq%EKkdeHSUJd8)b(@SV1mhuXC6exM4CGc z-+m;PxP$M>RU{ZsS@I<=@q)r@3>=?_!;0TT!px-BqmK!@8Lt`tKbo$>FY5K_F1R$3 zQc8EHbc2+1ceivmOG$Sl4bm;rE!`sBDIH7q?z{Ki-}?{jXTML(oH=vOJR&)k1^j>a zIZyiOTK!*%e7nYpjk|}`l{dCd_XVmkw(Q3Ad$3j3L~3NBKJBQ}mi)zu`b60vnlT67 z{}Aqgn#bb1*5#MHy?|!F9{9B;p=ZGPgl248{lOVMTlWX0NemYBlu?)9G{npoZuUA8 zU16k<(Yd6s(oAzqmT=rn(~^0)34Z9a21e#6y%28-AamejT-C$(E71yws*JivKC6OM zz}d!^*%9Bey(2fwC9~487B)AFv3toXcp)#n-U*7k7j_Y|U&K2yUclo%->ZbnFkh0I06!DFg382JZxpRWSCs%CS6foJc8?pB0?GsE z6VwD~!rJ+WKe!$P#ZpW4`zp8+VyfiGwkJA{HYVvL`J%)Z@|NE1_v zD5;g>z6%fHP+effA-TxT3nbxPT7){zZR&qWtoA(rjZW396s9xfJ#L zyhxon0-O0zm4f8?nLwkAeFx%zZz#!%IXIyM0}{v*SW$8a`YoJuqv$B8fq&XfKV5os zgxb>5&$Xyq6tVthmunS3Do#?=wWLLKIA7qH+yldYHUhdLDmS?4^D)wo!x)drcSL+o_Zwn6V!SV zC-7sCXwzpal3G@X=|_c24&!r@0F}=oUNwMtSgccduj4Ot zq0+4!*IkuGg>pJg?-y2i8z3FCIXy&l2*)OiFbIv=gxhe4-#%Gc5rUk>98i6{rbLEy8td^=cqf z&BbG>Q*u(e(WK+xB{X2zeXiEXv=8IJlaR9_HZyZ3lMwQ<6aHLqlSctp3@5ZlYjT{9 zOqR<0ZN)s+Bw8+6?PNe!Ynpr_Dh|4!Q{B)&A_n9Rv6AQw(sSmwQ3CT5$|Dzc#5*ZaC@VHD!PFXM%ODC#Rydl_BS(#cnerVVOAWj7pESL9-)^sPkDI7CpB^D>kknj4o^aQe1%^#d@SNF_I}Yk| z;0%X;_gRE9$8;6bUPq#s2q!a`(P%X&kXC8q#}523N!=eB^9OtU-CW!b4}XeK7XP?F z-GrCJtvTIXfqz}OrU(TY`Vc29NPN+~d4VF8Bw_Qqq>@s=Ig5QPzh3a%nu>2-_I5d2 zJn4q`t;C%^zj{k0h{3UspMJYoTyyFaEoGbTT>Y30wxtW_qlR-%DGqEA2$K0>sVpd*H%kqZ;s}iM51xgteq)7lt!g2J9(*gpRQe z#j~rKo&bZ>D26abqA7Ga2Er)N5#RkqR_7BS8Fp>4aA>mdD6#Np+BuCjx9poeVMG)D zO0u*OJg9#8WT-iuE7;%^;6dp06cy~+)jdVGka{a9F@u|TjOryg%WHcQod}|~pxXYV zJ)(`iA6DOqz6uyuo%|WN2=22QI$zIOSM8f3w?1SE*rYkcDLLAS9kiRGcURr)v&-1( z;5!pP-1fk>Ou|Z6(e0fTcff3H4RR+?H4OS4B^P9J{A^M5xqokI!o)4AN5Q@YS(CVn zQTO#Cg4cXdN`;=}M&8mIi|}n<9MFX(%h7a_+y0VuTwZkVS{VxScb%isDLXjtWU&aK zbr=M+@9iw5npU?7Jg_NICV6Xo2Z~W9$x>5R{^|*=8iqbi$GhAP>7L(+4mUp&uR@3g zw{4|;5&5P<3`;URII{XIqFHw8to+G7@gEc7Ry|N!F)QNX9=91#X#JU+lh!JPGw>OXE)XPTz9gyAF)p z{|5GMGlsf|EemAO2Cd<)eH(=gji&O;3r8P&<-PPgUI&sqV6MLO0s(&d01Pz9z5ecL zYIMLNBa8J*mK{6OGg)1ohl9om`MR>6*0=80yE8J;i{0Bnk-!0S1#(^yh_H(bPiGy_ zm}qQlt5d}%ie;|}6Cj=hcqpH0kjBErA~8LpIyG@8ND>L75SOu`$0jnf2;vA(XqLB% z&{3x>{B=tbj&1SAzB`7yqa2-3QxdGIn`)V-AT-1$uwcqLyi-Jlh7pcuwWWKCkWTQ% z8g2R@|JL9k`rs)4HYG5u?NbFbv?$Jh_ieBfFRzPMF*Ue3Sd`;s*eJqkqL0-br z{j7g&f(xo5b^G>XmXwg}ik^Bk5f8|9q+$fH7jBZn4%^h?KnxoweMECiJMt1s;+DC; z3@rIU3Fb_o$tbRitBERY@5^!Chp%+J{7NWJJ{vEKV>K;lF`Hzv*tl(`yQYAo9p26{LJ>}p|pFgt>uZojxt7P0PD|G z!UuK^NDo}|i~Tr&-<-eg0zijSdAbuhpRbMLRrEyI;p#Ur7iVWGKK{9UD5RnT@R6f9 z&oysenttdB&+3Jm%AVfuMPbMT<`!@1`=FiNq#ub)&pqS%R_);ny7KOPCPW(5Q2a`;_Y(*L1M(~whpPAG3hFUtpd39L*|wl z20Wu@h62lo3yx|Q`Z|pEWtLu??m;6sIx)u;V9-aA(CS<8L* z-EmcVk4NR!fE#~TTI^X#j*5kS0_ypi-riK4rF6fhrt640^cD)N#~w#nMVhK^~`y=4VZYDSoivJI7qd zz^pWNC%2s_vdg(gFQ30@39aBcL}T4!w@1b4o(B;8R;&9_UkWP>U*^gE|0($L7bOg=PUG7th=nrONO!-pZGrNCvlxg@nybqfn(%@p$dhC739c(3( z7EkklszXI+j+H7#I6WPnF6pga;lu!Evp75SdU7UxC%}_oYYWw27x=(5efKzhRgYeM z*Sfmy1aG1>EplP*h6CVqf|qzrSAd5s0|94ZO)P2o#r~ON!)phY9r?f@Qq!H@IR;|q zWvL~v{T%~QU{<$?+r6I(SeZm)QXVjY@=|s#^~@$|)A_;TkG1ICD_VJYyQY}45qN4R zM}>b-yTJ0@o<*h3Tb)}%;z|w%g6|R*k?#aE?Q!jR*j)+y$!4&#Y7Q-6GY80dJKp_} zl4%~fBB{Q?79^j=9!g)Twm5^0xGG5|FsjrMJQ{5l7Q6tSa^*P%RAn*chxCW4q-oua z0MZ4$Tp?G=0&6RW&eo0N3%(I&ff?)dk#6JVr2Q7diBqT)#+Dz2#Ct$FGk2W*GnAob zQLd)g{hU0^*aieNN*F3{O34|!e>`A*b5lwGRSwh+PRCfos>-%lR>W_=X!Jde5c1h~ zfb38AOm4n@SPWAAXzAZi#Q>Km>;Ik_xA&i`Y)|3L0jYo3^CMD*4LrJlv{bG6U-pJ9fxJ%Ibr(T)m^S~J`6Q&5Hd-K1&6f|+d7cCRitGj^V=`oQug50+o9?96ooyntCA+! z+D6#;3H{K=XH^ccS71f|m1{ytr9Ag(gDHM%ch^M6MefcnX1#ii!8E#KU6@ z3VczQOx|adv`FUlgv|K1(oG8}^i(cpG1KVp@tqyvc=(E>I!H^ zaMCv0@Q3Yd#DrvR!~N!xFOKHA6SIfZ7vd2G%Ep2Q5TT|9yw(?MC~eTJ)eHXa;&;4d z#m@2%gMR2=O1}fRXpC&F5%f+;zAjc~wit_?_6+yypI|~1I*tR$XI)>Z$efIqLlr`{ zJqPuy9RMC{`1N%&oAxH_wK~(R_d%7TreYa;W6^Yu#b}-9L;AkmjH#PP?R^Q3=H=9Ftx=y zefMaGXn!PgFI7(z&ky-d=GTe}&5A9&CLTTmDF;k>exokF9L}E0*T)Rn9X-z<5zjfq zn@Cx7puYFgefJBO}r207LxZC z;446PVXmSZKx9Yo2xrF`h+|(X&F-51vynLM2QU zH9#k;27tl%0cBxEgBQGh%)rDxP{Xgu9l5nhG?f4pQUADZPzlBpn!V|WV4}z8*f?RR z3$;g%X^k!kVRS`qU-k$!gu(+{S5#B!=YE~9@nRi4(v+n8{M^;|V+cP~a{4y1?JUd1 z+f5B{PyCxKaDvL-ZA&~JvfzFTdg@hYyb_*&Ir>1w38Gz}ILk(Tx*w05=-_q>1s&^N zd3=y8W+bGe&eYf_n;J;3qoI;HD*(NnPJqn$-A$o-o#&W%d4Cg!+J>JKJ%w5mwwHl(ijp-u-H~sRk;>)(=*j0Ad`iuxM`w)w$UQ36Zs*y5=??o6WvP?l$^W^8Z~V1cpzESSeNSXUHV!M$y8pS!>DFS{rXFRoZ<=4D1=OTF-KYh}h zUONRxUiwKFs~YW+&0O)Z-!`uOMEqE@xI5ey2rCACj!J7c1mn(+3Y8))d7g|Td1{#T zFw4&lvpiH407t56b)vEyTh%#2;kvp%^M$Vip8R zEY!wyY>vN=Y8#i@`prBuXnIDY*Gm=|MhWB>77%lU1@~f%iLb_(XjB&F{Xtu=pI`QH zyAWI<>(GsDHmF%}LUYD4F^RQGp4HZr$|Dz_TXb}aw{S4#Fv>O`E3u^RrxM&alK^6t zLwUlt!u=2oR)1ORZRI0Y2nyOTB$2KA4ny?*zU49P#()MVpIQ!<|EO_94UscgQJlJM zTYcw$+*}JoE@=A z#g*(NvWiULlPw{I2>YV{+WZ4Cg>)yLp^BTA&B3TMv>r}X80uCiN$PH zLeMlc3*IA)VQakUYZC**p(GBQevn1pYgS%plPq(~Ryv^d2_)c#Z4YJc+vve;=A7=K zQS}yn7uRg=<-fZLhz5_ls6kEKgL;3D%Fr3!cIckUGn@X&kSVuV&pb;$|8K#flPdAsO)|8q-1w4jO*L3m z+R745u&rJGl0f#Rc^^YJQ4P!@JHReMHnQ4Xw+M#D0pYRj$*r<19J?yRM6Jhp zhK$6W931g?fQ=TH-yE#u;Jj`>hA;*V>Fqcx(Z}LA46@FS-mNY?$4#NBsRf4S79}piJgYL_ zXv1st62VY1l3~pl4b3SV4faz4jS?|JIPH;ZXoAqQr0eyY^gYf{@qFG*_xw9sTAX}@ z|Dsr+0G1I*csB#&fnuk`wdSAnCjd|M34W?)WF)`z55``)Bk70$X|`sjP~ri_w?}Kl zYN_vl6pETLXzM)4lXz3;XOb%KmtaCH#r8L}R>2Xu>T8?WPnTq20S0F?S&;i>>Y%Zk z{QLKx)^`t9f?ZQ%TTB3LL7Rb%C$wC@A zbyz$a1Lar|k!_W-h*`(8QP9dNq}Z1;LB;Wsf|7zVFQ?cUxB5CViOX}gWDto^JgEs) z*)a}Mf1rEUg%u#X0Q1ufIo&cG4-jL}7Ih3-dp2W-w( zlZyq6cNw5Y_=k6S`3O#c%rH82-BRlpTN9<{n|nQQ_8;R_I%BYvUH%Xod6$9|Z6vke zvb1JiJ+!rnJS6P}8o_Qb5BS*tXTP2jOWt(y5R4L}H7NQSnpJzaswE1#PCuy=g;F)1WS;+qVO_cGW3vVovVjHkD3=xrIlp8GT-8k<>z-}i5iqxbtrZ=-)9C@fy`mo39(Z!_cU+Yf2r zxqHk$JWfFV?Cy?n8*43@DjhSVcuIT#?ZVDgs)TaY9^Dc*4xtJoUMVovr_8R}{k~^E zS#eMfjz|acUG-h4b2tw;zIIJPWOUU!+0u%c zGDeG=GWcJyIaM*~O_NUx(vNu4(*=BT3gxbV8fhw`yJqm=SR@O0bNV3(;r=1RSY|jA zL~|b`O2SloGw?Kt^Z~96!EdWyGdF%&;uF-?|L#(3ft_);h@!^P;~iQi`PFJx^xpVG zZJGlclp9ci|IsJ)W#wSd&I|?4u#&V;r;f|h=dDwM&tul2W^VEiHUYt_ zUdpZ~`|AGwCvIU^LTI_x;J+!Uud^C|Iqbco$AqM;>Hxg6ZrQRzzW)m$IFGlWyz2~* zckE0P-I0@jeBYtv*s*4(Xu>W^2yuix90PEcg<~C6pHBfX62XqctY}>Hw%Uct4}I<% ztu03l;)%+%Qho{`G7UtMAQr>cya`lKz>b}VzInQ^Ce*%7dk8%c2@jT01x#?^`4>?4 zs^S5VsQF0|?{ldu=ujuB?pgu>w8o^TiQezHdi%9}3tq*pA$zLdF5djEZa$m&?W;Va z(^NJUOk~UOKvh`s|5^b0q8*yMOWyq3$SAIJNfzy9iy-l?A`A&z=aRlORHM;P?35Nk zTPkO`zwk>ohK>-40t9gU?qfCtQ9mL^imiU_UtDWnBZtzPY8i90^g21E_ zth@P91WuI*D=4<-p_pdlC50PuT&L97b`AV{$iTy!zq2r;j{b~DMV7oumVDrJx#o6p zbzBv|YWO)kC~AO;ngIZ#_;=_+VG@z26C&7T8{CI&{^s+*wC!_V!Z@R$$1;>^F{^qK z$(grHd2}b5vfGKj7SsMnx7!w8GuNO$M;iNz zR`Da>J4e;KIK*lAqWfNWxI&$3!{>li%H=B;9RYuXe~uklz6AeZxS^Gwp!vrovq7Eo z!-j5=$Br~vYEqPc*bZu(1g^?o*-m||`pm-0z5k(X@JwhtZs&z(@G$>F9e-wDSqTogNY0$v36U=@XTUk2gO~two zi5i`~P-x*e$wiB<2htGok2cTt>;`TACw#c5@!AF37y`-GT=V^b+%gFzT zJ#Pb5ybWKb-Gt4Pt0^+J24Nv3&Ak5S4Cj7r=VF3jIu5J1M>iw(itur4R4Ti4ERyAjS`Sjq9vMbG9|OTXpi z$|ol`kn~IH>I$h#qUd-r_{nMf@WKsqc2flSx7SoIXvo~m=vU$TdqA8U$gejPi7_Pn zaG{PUth4#Miv50X><+U?Qy610F@9=)j%#Ky1}7dfX%%TP6=$RP@LEyICiS#a3W3)V z{s(534HK6mf!vfuUGFDa&F;a4g`jmZzeVEXNG;Qr&#mTkiTqKUGBqj3=$n6vJW{%!toboOjkiuOQ#hMY2!n|KvrqGR z=Ky(U5Z5%-6}r|`;wsIRQ2#!8GtmKEBEnoN;Vhvb=?NWt5yc%xHfn7A;FL{xIRbaX z`<0)-xidJ*rrpLU$D;|nUFEgevV&4ZknnBKbl2S=nK(jMK2mQ0$6pHGgSE`sPh<}H zm=MYHBy>1TZ>Ju-5e2LO@W-IgJH3RFj_KRUlsjb-X)^+OhpA1W+S#hC>uOo9LN)P0 zIJ~VHK=%*6jN-lmBNZXpdCrGWl|kcfH84%q&FC3V)-vVv!jZ+|$iy%Zg!@8rWuNi4 z#37z+b?;`hLkJqz@v^_UEdE{KVFy+@ZPUYN9QUMUSVh1vW0Z2#f8EF7=nJBap(0Uy z$f)Si)dl4EqqeAT@L5eA7cqpH=AeAyz0XYXY=gVs?<1cdYsIt$gc2jF940Jgzy?Ik zP|O)(Hryy2e+F!qCdlTTcoKOZmPBr(7zNsH)}bm2`K}R!#|_k4C0}z-cCnGo{#xiJ zW>UAJGe~l|Ct^Fx!%r>coQPvSbhLfg_Acvs9!{RmWL0?fjTM1D(*159jvif=6|VAb zdiqm|%85QaA-c31a>zBQF@xq&#pm+Rp>Bvj#lRNL17kr7o}+|+W|Em#DOy^NJnI^N z+)z~6kzEZOVd@IHq+p7y?c}x~oua>~qII?&Y-~SjYdiEY?+&9$dN|ri?&a18ol$7s z<3la)dV6Y3hZ5BG zEI2Y2Y-VeHiBsKmQ%1AeSJBHX z8O^OY0XVQjKCLz^2qXPhdMCWiK+hO?(1zY~IIelenciLlpYhn)3aW!g83Yhl-7LPE zT?JXeIi&{0;RG}wBMyYZscV1wkl|KgI01(|__HK;%==xAPK_dblOrbkt&J#A!bOTt zUv4wyO8{fDbLz`=U|-L~=>gVs(DRvX50V_T@N;Aqcl-hQN9?OLVG*&bo%%7Tw=NO` zz)E{gNaig0Wy7!i-iMD|hP-BPl#iEsl0m_#UHM#GQx%>udWN1`HJ4ap)s9#+JER=0 zPD9ePz9jXP{-g!xa1U?YK0-);_?u$vvh*pUnUl$cW_3Wmf=H35p0&og*f~Rc-r1i9 zpZUwOj`+Im1aC{+sC=VOADd?Y$(7KkfnACJD8I7Fo|#zazII02-MQQv7`;$n*yce% z34}O-FqapsKwdTCm)BV)ivSeQotULsY#swPyML?42} zVT%QFuCg36Gusl5f3Q;q?7#AIs_!iF!|le}J!dw=tcr-ze_>Kv8+Lalerl#1dR5rd z(C>T7i*$qFl)rgtn*~}ZfkhAe^-K?ER_MP?PoC0kM0cG;8ooEr=qQy6yny_Ew8gir zHHZCBHxC{&TlhkMuv%QBsvR5S_y+RzYf z2|x`LUHrERctawRG2O;PcUwksEZ6YGMi!aMwNbKV>AQaQ?Abas?+2I@{YTW2pv9mA zemhyCt9|a~W10T*Mis1Q0SmzD|6|)&io({eFd@TuPuT~mMtz#b10C8bZlqM!SRIl0 zaIOg~jP|@H?IEM+A$^3n&o5F~XqmJ@oT_HE9$(vQQZowx4=P@j4veII-70Oib567BzR|079$IfxX+Au}V2cPZUux z3pjKUOWDLrqHw?XESy68vu0fjYp&lQtK$B+AD||!QVlreUlO;g5H(&EkHv$ZtDzcF zct@$|I;zYG0N&ni)6iyqYYxr&6k;pD4{z?%T}g?<4jdTzo>5*Tj*^BW&GF!c=Uvwd zsKxQO{B6;|HF4PDBuXTK z4Q9__s~463XhSG3`BMHB3tifRC*_H}*w_lQ%ZMEE%v*0!|ANt4k7@k*BI?7JFx2SP zG+@wCDDhS-S^zvuITS>wkh4eNcbi?nq`>&I2=%=l%Oy}kw7Q#aaEytKFa{!V-dMBvI19L8if%EB%ur3X`~No3gO1J?6a^p zuEAoz^x+a$t-}h#GD9#%5DG5{--7Q(w?Qey@ki(rSE1QbVNpdCBOB+*YLiS~w`gdc za&?LOIwjCGg`c~DNQdv_a(8M7+1Om&|MfM{AvNnC?aY8O!hgXj|KsM1l{B~nfH@qD5Ifn>W_E=-Dz8v0i!nr~ARoORzlyHca;nT&u@YZVlK$Mn z^!TS0K#Y|%H8$a~-}Nj9FDoCo!P-r>kNq1}wyfJJz~ClQE=V}}>CN8#bu4oB^!oT? zi}^!DT4=r(s8o+S^Re{SDzROrplilRGkD^sMl7`Y!VrCqu0vZ@1D=t>NVnnp=7pgl7Cd86 z#Z)D@}9#DO=Sf)uT()EZqw?1!OC*nLz9Zj?hG_l@`CA1N~nuKi-=L!aDLj zZ&Cm&=RpTsj1QeavFkRU{sT-2OgYz)zD2h$lZqFeV+v|k)0Ec!0KG}1m7A587m`xZ zl}z|kU03AjI=aQtz1?J*tlfnWc1{me$(-qnx<4aX{8{Z*{wVz6 z9SDB+`yJZTu>4XBrs@9e>(J|&7b?Q5lHM%q^Ie$6C85 z{PS0M80dqAlN6c%t-;=o7Yhh7ozTz$5vui1QZxP)k{Ult<8gE{qUp-xO1f(J|MPyA!YC%{LcrACWKPFl8ll0kfoQ9N_W)7!e-$v$!| zeLS7@5A7}Hlk9YLtEiCSKOla+Ai?qCN=VDh6Y(&|&$b@(FS`-HqmRoLM6E;mUJX*( zy6&zSAu?|vtEYwv8h@lMu`e6V>vfdbYb2N|! z-t5}e?{ckFG4ik=fkEvp88}`}4asq5?Q-eQw29f+J}zlR*Q~?o8@mq;Oga1z>|t%E z#vBKAi-35qCx6J12;DUL@_Cv^+P~i#H7)dCc*M+(AwcVflEnw-i3?2cyvV2a*KOaT zCFV+9S=zH&kR+S|6|fUwtr9b?zG$hrIlOs+g75S0pZ>v)J}K?3b^*8N2{L&E)%j(86` zFNAYpgpV&^Lig0jWh55ORNjGuC)2)1c>1yD4pW01sDg|2VaN4j`?X!<1}-IOU+_Ja z5Dv=|N?*|iR_EZe&ff$K2%<+cUxXLcnlqB|S#q|>`U?hZd?$0=OC~Pe=E1K=%*TYl zOIqkrOY^Z&;MZ}~1EDQ-W6(di0|)niJ5E~}?)38Ky;hHyZXkkoU=%r9f$RH}8WTVM zW_{CY@-|8zbco`xc{OG7n{+I6`b=850+-lt$mO;<=er!)y+RY8VkWn`89F-J8Ee}? zI0ZHDY%Z7r3-5`Ur%zuGN3C;FCiGda-Bz0|>&yyRt`pMi*SY@G=A!dG*%I5o=YzW6 zZR8h|G6kA!+|A;=-W~RYX1#@Ym?849_08A|2s#&ZI~KMDrXdvaK! zZ_#|nkyDNim~z1+%J>(2iE#bLR}0ZU8IDf})S0|?Nj(L|xgPk0zd99cI~Cl~)WhCz z7{>qHe8_G{h6~UlkC6jpBg7&k5zllg0S0^yq>mpD;+#Ojt`1so1(Y0G#7OOmCROHc zy-TwWcLQnXb$6RaCEfiEeN*QXKq;s9KAOU6XMYMmKFKCw~%`wrZejJ1*@Q7Eb=e` za7IuP{UZUJ4SRX0ie_siWh<&j^b>j~jd$pMUM2Ak>~DwftrgOX6f6cLG!5SK`5nK> z)9IQ#n6kfms_O8+&Iot@5*cY%qp+3S+j6@x#PwfHTqgf7U5cwT`CsJUvWM=XzjE0- zb%%Wj#6IYb9y?%PX4ExG#FN;#mgObS4Qd+LK1yFXWRr{?C{Plj1@!&aV#^+KS8{j4 zHqt)_JA`%!lshzQSKQC04K@{UUjF%GlNw zkJ5tGrs(K2GkIQ5ES-rP?-aPvS5e>60_{Mo2U`&)+{yooI5}u({-rDfAI~<=!Pz}+ zV+k9g?AS^2&?Mf(Mrqx9tQ{b54G4!YY!KaK@Ejj zG0lrT)hPQ1ET31!FG3LxW`97thI{U}j4>jm?~5X6;O?MXHv-n1A_cG8foU#!Ra_Vb z(}5A$UCo%1IlLEY;}cC;Zp^u_hjU_~av^fx*z^*<0l#7u{oI^)iV6y4M-MBcLJwm{ zw$TlVuWzQbj-l*Ps&o5Vvf)=<(>YzCuFdbakMFZo2yzhXS^6uuSuteyi1eI$kBMMp zoBs1cIEv^eFR;7jiauHAAH*apRQetNACY7$nFbdmr3VyZ2l5P#yJef%d_@eM8Al&a z@z$k1aUX3@uRf_KQPx6kX3u2JG%!oU!3_;8j=VL2Ur^zFl&`iz67MQdQmMfOE)z3A z)++BOz70EEu{=JBIQ*U6Tr|HPYTU6SdZl~EcZPjNHmKsn)0(S$lE)ETIU5Kkqcdxd zZz_cEtOZw}Z~ZHEq9^dEz|L0(t#>Y-E^|2pQw2By)!OCyt#`Pm8gm@J)#s-Sj#mk0 zdaH**8w5;vLiNDYi$|Wvc)u_@>5xvapnJMbeqk_(uida5;eMqro7{o)+Rl>?~TRymjV65w3(#na&*q7ky9D;%uZWwEC;!bk1$gz#c zV^Ey3xixXQnj_>9^S3Fxx_S*(D|iH+Erwqc+=Bm+!R{bWe29CJiw#wT5gVy<@c*#P z_rE$W?v@_F{O68PA3Xb=d=Qx{PJK!evsO^BpLb4*10HY2oe&<(fSySbTcoWmpd%glXPG zqkKDgHE1*><@qOO#!NKpkLyp9$KOVua!TE?l3XwDZF3gSaGSiTqH5npg@x)RjF!N?4hsM#b<)fp}=uB z4Ukr&M55TR1D(v$G~b036*?L@QMOr?28<_dOaNQ`o(dAwgv)52t(#qqS^&hl5@2V2 z1Yp(`m5@n)fW>)SSOCtev~h35cK~0Ia4Bdv8nTySzT7^iPsZm`*o~>qCdcGl#*NBJ z8>cX?_G>C9@6Ft<2bF9k{m@>*LL7Z6*; zhT~g@<-Oe_i7>269K<2=Rk{_LuJfLXhtIkG=%y!SHE9Q{I?eyb|A?2P8b3Gf-b{|S zf*G?z7oF!1RIiD%_@fxwIS$!~n|Im2QtFZz>WVAkTf!{;`KnmAsym|TmnYPjm?4!l zF*P3ZJt#`6eM~hb%?wjxr)z$2C=n4&utuZ(o!gl9zVbx&?ptso(|O}i(6hi%0@X25 zPuRY|xP?9urhIFQ#h?LKpQpAsm#9-bUGd?>QeSg}`3aIWhf4!zhv^IUhj{^U>Pb!8 zinrgI!x!HBpImp}UbjV>oqcyQk`zjxX(dM4%Gs$3atl|n(n#xzdl%9_0-$O+vLyS% zB4T2DiCTq5Hp_7SJ5A7ijpWmLeZ6zW{5D3HeQ@blr=d|IL5mUR zBuB3(h;#6Ir##3uO1xRtvI9B2@RphN?Wlcztl^beFA48n_=Vf4LOv-~!QRzhoOw{*ZNsV=9|9V(2=K2O1mbL!c`IW%e{A4~Q{7ee7bt8)2|8?kwtN`dw8vIGIb7Q4b z`R|!wS%~wd=xYoDvwK@W_3-dIX-m^^JM)jKVWKp;%fP5Hwykx}ZUYJ%0@&>AVtT~L zn_v?ACGko_-$$NdKmD5@EYUBlx%M&E%8qXP6ZDV|H{p~SYMkUr-U_m)|0K7}VXrAq z6zCn%OU>dkiGSKUB%@-@M?=}f9y8^G{jhlqPKx%D0!Nm3bx;EbnAZcd+h)#4{tPS{ zzos|nl@&dG>|kjHzZrOU7QtaIFM4s-@7Yln5qN>5K{vl2pby-Czq1YkcxRe!K!&}+ zzz0(YtRk+Nrwx`Jea`sb9P~ggNMth($}TD7iNl8aCHl94?EHndh}=fOB1Pvo6%NzMgwd<28{dp0&kbveo?k0h zZs4+f?Xgfd-EGfZF-YTNy`9Qx}wA$A9X9p?A=sB`1 zVH4QAt%cB|SE#{&k|--g`X8YFXwYf%IFr!M%vQIv$DFNS*TD6XeVT6k-B={yfN()9 zU2-Fs^4?+)IlH4_T$D%V%W_Y2P*m^&UoS7`*$}M-^C5{}4cAF^{>J{P^B7j?_#5X9Y z%@!kHUX} zuf(_zu61p#q<(-4htqAAoN1f-d|?>RVzfP4lEByKnSE}P9ltJ>@g(%>PBh7jNjeLo z_efd(D_-BWINs`<3Bx3dbwBjGl{R~R)rux$aAleiWsB!&FEN|A^;y`l8GinMEr5w@ zXaoPk`+CG!BJF4MUhX907-^s8@S#>G9QMzCAqM)CiX?oC_yF_SmNy`80R@KN$?t#0 zZr+_rd7L|9phDSO*tBd*?dE>&VM9dI79sNV|qk{Ixn-r%)ujuSoyXtDA-A@G4?)T2b4 ze{88PmqmY9nh?&{e9~0{vT|n@i8MA%$`8sdP)?!K8y$nPu2zo$J#O8RBNOfJx35!z z)_kj6dl(U1{o50#tlAlPo?X6)4j8~X0v))!T7Wp34m;Sp9bCl}5qsc;Q^&hkt$EEfBJ54C7qFCtw3T6drIUT~rql20>WNsp zzn_L4^W7kHR2SIm3D>9@NE+{4qOGF8pub32;)E$yz7$SwHR~F#T!F+LIQ8N@(M`+& zgbG&hazuh1oBX}&62L?jQm3Z$T;Q{y#c(nBTpJw7TMRvST|Kj1k>FaM^DD$d`w1IG zV3Ge*LhNAjPqlwFn#b$wcU{LZ#6LqNKzt;*fy-n>iXh+LddNoQ0pzKq#=&H7QRa~= z7K1ILUpVClhfnprGg5{(_N$ch59XZ=c2|`*!Va{tDu(r6LPcAz+i;i>AW;L7J2IkU zIwQ}WjXxrd^mdNV`g4q-INA}$rZ&pE>v9I&WM`AJZV0ai&Pu9Cx~3NVs;5&$Bf3cC zU&`(Jv_tMg5D+-$4rB>c7W|05zNvl;&~OwRLECSPZQ{3#*VF-&d-5gICdsZ@Cj