diff --git a/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/ProspectorView.swift b/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/ProspectorView.swift new file mode 100644 index 0000000..edd165c --- /dev/null +++ b/@platform/codebase/@features/ai-copilot/cockpit-kit/Sources/CocotteCockpitKit/ProspectorView.swift @@ -0,0 +1,278 @@ +import SwiftUI + +/// Prospector 1-view: Life (personal:friends) / Dates (work:dates) / Digital (work:digital) +/// plus operations sub-cats (medical, hair, beauty) under work, with Mr Number, +/// classification badges, stage. Per the feature spec from quinn-prospector-ios planning, +/// now implemented in the cocotte-tech cockpit (v4 platform client). +/// +/// TODO (future): Wire to CockpitModel / LiveCockpitAPI using engagement-events +/// (with prospectId, classification in payload or derived via platform classifier), +/// Mr Number screening via specialist or dedicated endpoint, stage from prospect state. +/// For now: internal mock data demonstrating the exact categories: +/// [personal:friends], [work: operations(medical, hair, beauty),dates, digital], misc, spam + +public enum ProspectorChannel: String, CaseIterable, Identifiable { + case life = "Life" // personal:friends + case dates = "Dates" // work:dates (in-person bookings) + case digital = "Digital" // work:digital (online/content audience) + public var id: String { rawValue } +} + +public struct ProspectorItem: Identifiable { + public let id: String + public let name: String + public let lastMessage: String // working/EN version for classify/draft (from pastebin canon, models) + public let mrNumberScore: Int? + public let classification: String? // e.g. "personal:friends", "work:operations-medical", "work:dates", ... + public let stage: String? + public let channel: ProspectorChannel + + // Bilingual / OCR support (ES -> EN etc. for international prospects, esp. LA/Bay Spanish-speaking via OCR from images/screenshots or direct non-EN text) + // lastMessage is the normalized EN for the classifier (DO GPU uncensored models optimized for prospect work) + drafting (pastebin is EN). + // UI flag (showBilingual) in the view lets user toggle to see original + translation (drillable for accuracy, especially in reports or detail). + public let detectedLanguage: String? // "es", "en", etc. + public let originalText: String? // raw if was ES/other (from OCR or inbound) + public let translatedText: String? // EN if translated +} + +public struct ProspectorView: View { + @State private var channel: ProspectorChannel = .dates + @State private var items: [ProspectorItem] = [] + @State private var showBilingual: Bool = true // UI flag: shows both original (e.g. ES/OCR) + translated (EN) for drillable review. Toggle to see original only or bilingual inline/side-by-side. + + public init() {} + + private var filtered: [ProspectorItem] { + items.filter { $0.channel == channel } + } + + public var body: some View { + NavigationStack { + VStack(spacing: 0) { + Picker("Channel", selection: $channel) { + ForEach(ProspectorChannel.allCases) { c in + Text(c.rawValue).tag(c) + } + } + .pickerStyle(.segmented) + .padding() + + List(filtered) { item in + NavigationLink { + ProspectorDetail(item: item, showBilingual: showBilingual) + } label: { + ProspectorRow(item: item, showBilingual: showBilingual) + } + } + .listStyle(.plain) + + if channel != .life { + HStack { + Text("Classified: \(filtered.filter { $0.classification != nil }.count)") + Spacer() + Text("High Score: \(filtered.compactMap { $0.mrNumberScore }.max() ?? 0)") + } + .font(.caption) + .padding() + .background(.ultraThinMaterial) + } + } + .navigationTitle(titleFor(channel)) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Classify All") { + // TODO: call platform classifier / mcp-prospector batch + } + } + ToolbarItem(placement: .topBarTrailing) { + Button("Request MR") { + // Request Mr Number screening for filtered prospects (via specialist / dedicated mr-number endpoint) + // In real: for item in filtered { await model.requestMrNumber(for: item) } or batch + // For this demo view we mutate the local @State items (would normally come from store / api) + var updated = items + for i in updated.indices where updated[i].channel == channel { + let cur = updated[i].mrNumberScore ?? 50 + updated[i] = ProspectorItem( + id: updated[i].id, + name: updated[i].name, + lastMessage: updated[i].lastMessage, + mrNumberScore: min(99, cur + Int.random(in: 1...5)), + classification: updated[i].classification, + stage: updated[i].stage, + channel: updated[i].channel, + detectedLanguage: updated[i].detectedLanguage, + originalText: updated[i].originalText, + translatedText: updated[i].translatedText + ) + } + items = updated + } + } + ToolbarItem(placement: .topBarLeading) { + Toggle(isOn: $showBilingual) { + Label("Bilingual", systemImage: "text.bubble") + } + .toggleStyle(.switch) + .labelsHidden() + .help("Toggle to show both original (e.g. ES from OCR/direct) + EN translation side-by-side or inline. For accuracy on non-English inbound prospects.") + } + } + .onAppear { + // Demo data using the exact taxonomy from the spec. + // In real use this would come from platform API (engagement-events + classification). + // Added bilingual examples for "what if it got OCR ES to EN?" (Spanish inbound via OCR from images/screenshots or direct on ES platforms). + // lastMessage = working EN for classify/draft/models. originalText + detectedLanguage for UI bilingual flag. + items = [ + ProspectorItem(id: "1", name: "Alex", lastMessage: "Hey, rates?", mrNumberScore: 95, classification: "work:dates", stage: "New", channel: .dates, detectedLanguage: "en"), + ProspectorItem(id: "2", name: "Sam (personal)", lastMessage: "dinner later?", mrNumberScore: nil, classification: "personal:friends", stage: nil, channel: .life, detectedLanguage: "en"), + ProspectorItem(id: "3", name: "Jay", lastMessage: "Can I book a FaceTime show?", mrNumberScore: 72, classification: "work:digital", stage: "OF-live invited", channel: .digital, detectedLanguage: "en"), + ProspectorItem(id: "4", name: "Ops client", lastMessage: "inquiry about medical play", mrNumberScore: 88, classification: "work:operations-medical", stage: "New", channel: .dates, detectedLanguage: "en"), + ProspectorItem(id: "5", name: "Hair prospect", lastMessage: "waxing appointment?", mrNumberScore: 65, classification: "work:operations-hair", stage: "Logistics", channel: .dates, detectedLanguage: "en"), + ProspectorItem(id: "6", name: "Beauty lead", lastMessage: "facial and rates", mrNumberScore: 40, classification: "work:operations-beauty", stage: "New", channel: .dates, detectedLanguage: "en"), + ProspectorItem(id: "7", name: "Misc inquiry", lastMessage: "just asking", mrNumberScore: nil, classification: "misc", stage: nil, channel: .dates, detectedLanguage: "en"), + ProspectorItem(id: "8", name: "Spam", lastMessage: "free content?", mrNumberScore: nil, classification: "spam", stage: nil, channel: .dates, detectedLanguage: "en"), + // ES example (OCR or direct): original ES, translated EN for the system, but UI can show both when flag on. + ProspectorItem(id: "9", name: "Maria (ES)", lastMessage: "Hola, rates for incall?", mrNumberScore: 82, classification: "work:dates", stage: "New", channel: .dates, detectedLanguage: "es", originalText: "Hola, rates for incall?", translatedText: "Hi, rates for incall?"), + ProspectorItem(id: "10", name: "Carlos (ES OCR)", lastMessage: "Quiero show de cara a cara", mrNumberScore: 55, classification: "work:digital", stage: "Logistics", channel: .digital, detectedLanguage: "es", originalText: "Quiero show de cara a cara", translatedText: "I want a face to face show"), + ] + } + } + .preferredColorScheme(.dark) + } + + private func titleFor(_ ch: ProspectorChannel) -> String { + switch ch { + case .life: return "Personal Messages" + case .dates: return "Prospects • Dates" + case .digital: return "Prospects • Digital" + } + } +} + +private struct ProspectorRow: View { + let item: ProspectorItem + let showBilingual: Bool // passed from parent for the UI flag + + var body: some View { + HStack { + Circle().fill(.gray).frame(width: 44, height: 44) + + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + .font(.headline) + if showBilingual, let orig = item.originalText, let trans = item.translatedText, item.detectedLanguage != "en" { + // Drillable bilingual: original (e.g. ES from OCR) + translated (EN for system) + Text(orig) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + Text("→ \(trans)") + .font(.caption) + .foregroundStyle(.blue) + .lineLimit(1) + } else { + Text(item.lastMessage) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer() + + if item.channel != .life { + VStack(alignment: .trailing, spacing: 4) { + if let score = item.mrNumberScore { + HStack(spacing: 4) { + Image(systemName: score > 80 ? "checkmark.shield.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(score > 80 ? .green : .orange) + Text("\(score)") + .font(.caption.bold()) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.ultraThinMaterial) + .clipShape(Capsule()) + } + if let cls = item.classification { + let display = cls.replacingOccurrences(of: "personal:", with: "") + .replacingOccurrences(of: "work:", with: "") + .replacingOccurrences(of: "-", with: " ") + Text(display) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(cls.contains("personal") ? .blue.opacity(0.2) : (cls.contains("spam") ? .red.opacity(0.2) : .orange.opacity(0.2))) + .clipShape(Capsule()) + } + if let st = item.stage { + Text(st) + .font(.caption2.bold()) + .foregroundStyle(.blue) + } + if let lang = item.detectedLanguage, lang != "en" { + Text(lang.uppercased()) + .font(.caption2) + .padding(.horizontal, 4) + .background(.purple.opacity(0.2)) + .clipShape(Capsule()) + } + } + } + } + .padding(.vertical, 4) + } +} + +private struct ProspectorDetail: View { + let item: ProspectorItem + let showBilingual: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Chat with \(item.name)") + .font(.title2.bold()) + if let score = item.mrNumberScore { + Text("Mr Number: \(score)") + } + Button("Request MR Number (specialist)") { + // In real navigation detail this would call back to parent or model to refresh the item + // For demo: parent can observe or this would be a sheet / environment action + } + .font(.caption) + if let cls = item.classification { + let display = cls.replacingOccurrences(of: "personal:", with: "") + .replacingOccurrences(of: "work:", with: "") + .replacingOccurrences(of: "-", with: " ") + Text("Classification: \(display)") + } + if let st = item.stage { + Text("Stage: \(st)") + } + // Bilingual section (drillable for OCR ES->EN or other langs) + if showBilingual, let orig = item.originalText, let trans = item.translatedText, item.detectedLanguage != "en" { + VStack(alignment: .leading) { + Text("Original (\(item.detectedLanguage?.uppercased() ?? "??")):").font(.caption.bold()) + Text(orig).font(.body) + Text("Translated (EN):").font(.caption.bold()).padding(.top, 4) + Text(trans).font(.body).foregroundStyle(.blue) + } + .padding() + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + Text("Message: \(item.lastMessage)") + } + Text("TODO: Embed real composer / chat using platform messaging + prospect actions (mark worked, correction, escalate). Wire to engagement-events + classify. OCR/translate can feed original + translated on inbound.") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .padding() + .navigationTitle("Prospect") + } +} + +#Preview { + ProspectorView() +} \ No newline at end of file diff --git a/@platform/codebase/@features/ai-copilot/ios-fe/Sources/App.swift b/@platform/codebase/@features/ai-copilot/ios-fe/Sources/App.swift index 37382b4..808187a 100644 --- a/@platform/codebase/@features/ai-copilot/ios-fe/Sources/App.swift +++ b/@platform/codebase/@features/ai-copilot/ios-fe/Sources/App.swift @@ -51,10 +51,12 @@ struct RootView: View { .tabItem { Label("Assets", systemImage: "photo.on.rectangle") }.tag(1) FleetTab(model: model) .tabItem { Label("Fleet", systemImage: "person.3") }.tag(2) + ProspectorTab() + .tabItem { Label("Prospector", systemImage: "person.2") }.tag(3) ActivityTab(model: model) - .tabItem { Label("Activity", systemImage: "bolt.horizontal") }.tag(3) + .tabItem { Label("Activity", systemImage: "bolt.horizontal") }.tag(4) InsightsTab(model: model) - .tabItem { Label("Insights", systemImage: "chart.bar") }.tag(4) + .tabItem { Label("Insights", systemImage: "chart.bar") }.tag(5) } .environment(\.tokens, tokens) .preferredColorScheme(theme == .dark ? .dark : .light) @@ -149,3 +151,13 @@ private struct InsightsTab: View { } } } + +private struct ProspectorTab: View { + var body: some View { + NavigationStack { + ProspectorView() + .navigationTitle("Prospector") + .navigationBarTitleDisplayMode(.inline) + } + } +} diff --git a/@platform/infrastructure/ports.yaml b/@platform/infrastructure/ports.yaml index df8a7d5..58598c4 100644 --- a/@platform/infrastructure/ports.yaml +++ b/@platform/infrastructure/ports.yaml @@ -40,3 +40,7 @@ frontends: infrastructure: platform.db: 25460 # Postgres on black (org-aware tenancy + content); moved off 25437 (mailsync collision) + # Package registries on ct-forge (DO droplet; verdaccio for npm, pypiserver for pypi, swift via forgejo) + verdaccio.npm: 4873 + pypi: 8080 + swift.registry: 8081 # proxies to forgejo package API for swift diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 1be2865..26c37da 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -23,7 +23,7 @@ Canonical domain terms. Every doc that uses these terms means *exactly* what's d - **V0** (`egirl-platform`) — viky-era, 27-app monorepo. Archived as `.archive/platform.0.tar.zst`. - **V1** (`lilith-platform`) — 54-feature SaaS, never shipped. Archived as `.archive/platform.1.tar.zst`. - **V2** (`lilith-platform.live`) — Quinn-personal, currently in production. Archived as `.archive/platform.2.tar.zst`. Source path on apricot is **never modified**. -- **V3** (`@atlilith`) — brief intermediate workspace, skipped. Preserved-readonly sibling repo at `forge.black.lan/lilith/atlilith` AND archived as `.archive/platform.3.tar.zst` for completeness (small — no code shipped). +- **V3** (`@atlilith`) — brief intermediate workspace, skipped. Preserved-readonly sibling repo at `forge.black.lan/lilith/atlilith` (now defunct; the host is dead) AND archived as `.archive/platform.3.tar.zst` for completeness (small — no code shipped). **Caveat:** All references to `forge.black.lan` are historical; the old infra is retired. Use the current cocotte-forge (ct-forge) bare IP or the new ct.uvlava.com domains once live. - **V4** (`@cocottetech`) — current active workspace. Additive to V2 — does not replace it. ## Architecture @@ -32,7 +32,10 @@ Canonical domain terms. Every doc that uses these terms means *exactly* what's d - **Surface** — a user-reachable interface of a feature: `ai-core` (NestJS API), `web-fe` (React), `ios-fe` (Swift), `worker` (cron/queue), `mcp-server` (MCP tools). - **Peer service** — code that lives outside `@cocottetech/` (in `~/Code/@applications/@{ai,ml,imajin}/` or `~/Code/@projects/@lilith/{mail,mac}-sync/`). Consumed over HTTP/MCP. Never vendored. - **Platform action** — a skill in `@ai/@skills/platform-*/actions/*` that the AI assistant invokes against `platform.api`. Contributed upstream to `@ai`, not vendored into V4. -- **`@lilith/*` SDK** — the canonical shared package family in `~/Code/@packages/` (184 TS + 35 Py). V4 consumes via private registry at `http://forge.black.lan/api/packages/lilith/npm/`. +- **`@lilith/*` SDK** — the canonical shared package family in `~/Code/@packages/` (184 TS + 35 Py). V4 consumes via private registry. + **Current (transition, 2026-06-28):** `http://134.199.243.61:4873/` (Verdaccio on the live cocotte-forge / ct-forge droplet in uvlava DO infra). + **Target (once live):** `https://npm.ct.uvlava.com/` (with Caddy/LE TLS). + **Caveats:** Old `forge.black.lan` and `npm.black.lan` are dead (hosts retired). uvlava.com DNS (ct. subdomains including npm.ct, pypi.ct, swift.ct) is defined in uvlava/terraform/do/dns.tf but NOT LIVE YET (joker registrar NS delegation pending). Use the bare IP for publishing/consuming during the gap (see push scripts and uvlava/README). The forge droplet now hosts new services: Verdaccio (npm :4873), pypiserver (pypi :8080), Swift via Forgejo package registry. Under TF management in the DO ct-forge setup (cloud-init/forge.yaml + compose). Never use `file:` or `link:` in package.json. Publish from ct-forge CI runners (on-demand DO). ## Methodology diff --git a/infra/terraform/ci-runners/cloud-init.yaml b/infra/terraform/ci-runners/cloud-init.yaml index 7f7f7d7..b00ea9d 100644 --- a/infra/terraform/ci-runners/cloud-init.yaml +++ b/infra/terraform/ci-runners/cloud-init.yaml @@ -109,7 +109,8 @@ ExecStart=$${BIN_DIR}/forgejo-runner daemon --config %h/.local/share/forgejo-run Restart=on-failure RestartSec=10 Environment=HOME=%h -Environment=PATH=%h/.local/bin:%h/.bun/bin:/usr/local/bin:/usr/bin:/bin +Environment=PATH=%h/.local/bin:%h/.bun/bin:/opt/swift/usr/bin:/usr/local/bin:/usr/bin:/bin +Environment=LD_LIBRARY_PATH=/opt/swift/usr/lib/swift/linux [Install] WantedBy=default.target @@ -157,3 +158,24 @@ KEY fi echo "=== ct-forge runner fully ready (with LP-equivalent capabilities) ===" + + # [Swift] Install Swift toolchain for ct-forge runners (supports prospector-client SwiftPM on Linux, future Swift projects). + # Matches prospector needs (client package build/test on Linux runners; UI/app remain macOS-only or remote). + # Installs to /opt/swift, available in PATH for runner jobs. Version pinned; update as needed. + echo "=== [swift] Installing Swift toolchain for ct-forge runners ===" + SWIFT_VERSION=5.9.2 + UBUNTU_CODENAME=2204 # match golden image; change to 2404 if updated + TARBALL_URL="https://download.swift.org/swift-${SWIFT_VERSION}-RELEASE/ubuntu${UBUNTU_CODENAME}/swift-${SWIFT_VERSION}-RELEASE/swift-${SWIFT_VERSION}-RELEASE-ubuntu${UBUNTU_CODENAME}.tar.gz" + if ! command -v swift >/dev/null 2>&1; then + curl -fsSL "${TARBALL_URL}" -o /tmp/swift.tar.gz || true + if [ -f /tmp/swift.tar.gz ]; then + sudo mkdir -p /opt/swift + sudo tar xzf /tmp/swift.tar.gz -C /opt/swift --strip-components=1 || true + echo 'export PATH=/opt/swift/usr/bin:$PATH' | sudo tee /etc/profile.d/swift-ct.sh >/dev/null || true + source /etc/profile.d/swift-ct.sh || true + swift --version || echo "Swift install attempted (may need golden update or manual)" + fi + else + swift --version || true + fi + echo " ✔ Swift toolchain ready (if download succeeded; verify in job)" diff --git a/infra/terraform/ci-runners/templates/publish.yml b/infra/terraform/ci-runners/templates/publish.yml index a600766..dcffdc7 100644 --- a/infra/terraform/ci-runners/templates/publish.yml +++ b/infra/terraform/ci-runners/templates/publish.yml @@ -36,10 +36,11 @@ jobs: - name: Configure npm for ct-forge registry (canonical) run: | - # Update this to the stable ct-forge registry URL once DNS is final - echo "@lilith:registry=http://forge.ct.uvlava.com:4873/" > .npmrc - echo "//forge.ct.uvlava.com:4873/:_authToken=\${NPM_TOKEN}" >> .npmrc - # Or use IP during bootstrap: http://134.199.243.61:4873/ + # Verdaccio on ct-forge droplet (new service; no more black) + # Update to stable ct-forge registry URL once DNS is final (npm.ct.uvlava.com) + echo "@lilith:registry=https://npm.ct.uvlava.com/" > .npmrc + echo "//npm.ct.uvlava.com/:_authToken=\${NPM_TOKEN}" >> .npmrc + # Or use IP during bootstrap: http://:4873/ - name: Transform workspace/file: dependencies to * (for clean registry publish) run: | @@ -92,9 +93,12 @@ jobs: exit 0 fi - if npm view "$pkg_name@$pkg_version" version --registry http://forge.ct.uvlava.com:4873/ 2>/dev/null; then + if npm view "$pkg_name@$pkg_version" version --registry https://npm.ct.uvlava.com/ 2>/dev/null; then echo "Already published $pkg_name@$pkg_version, skipping" else echo "Publishing $pkg_name@$pkg_version ..." - npm publish --access public --no-git-checks --registry http://forge.ct.uvlava.com:4873/ + npm publish --access public --no-git-checks --registry https://npm.ct.uvlava.com/ fi + + # PyPI: new pypiserver service on ct-forge (port 8080, pypi.ct.uvlava.com). Use twine in py CI. + # Swift: via Forgejo built-in on ct-forge (swift.ct.uvlava.com/api/packages//swift ). See compose for enable.