Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ All notable changes to Able are documented in this file. The format follows [Kee

### Added

- Nothing yet.
- A public catalog-only UCP agent profile and Shopify Storefront Catalog MCP integration for bounded, live product search and detail.

## [0.1.0] - 2026-08-01

Expand Down
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,20 @@ Requirements: Node.js 22 or 24+, npm, and a Cloudflare account for remote deploy

```sh
npm install
npm run db:migrate:local
npm run dev
npm run dev:parity
```

Copy `apps/desk/.dev.vars.example` to `apps/desk/.dev.vars` for local-only values. Never commit the resulting file.
`dev:parity` migrates and seeds a persistent local-only workspace, then runs the
same Worker entry point and browser assistant used by a deployment. It uses
remote Workers AI, so authenticate once with `npx wrangler login`. D1, R2,
queues, Durable Objects, email, and all fixture data remain local. See
[the development workflow](docs/development.md#production-like-local-assistant)
for parity limits and optional development-store configuration.

Copy `apps/desk/.dev.vars.parity.example` to `apps/desk/.dev.vars` only when
local integrations need private values. Never commit the resulting file. The
separate `.dev.vars.example` documents deployment setup and contains deliberate
placeholders that must not be used by the parity server.

Run the complete local gate before opening a pull request:

Expand Down
25 changes: 25 additions & 0 deletions apps/desk/.dev.vars.parity.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Optional integrations for `npm run dev:parity`. Copy this file to `.dev.vars`
# and uncomment only values backed by a dedicated development store. Never use
# production credentials or customer data in the local parity workspace.

# Public catalog discovery through Shopify's merchant-scoped UCP Catalog MCP.
# Shopify must be able to fetch the profile URL, so localhost cannot be used.
# SHOPIFY_SHOP_DOMAIN=development-store.myshopify.com
# SHOPIFY_UCP_AGENT_PROFILE_URL=https://staging-support.example.test/.well-known/ucp
# SHOPIFY_STOREFRONT_COUNTRY=IN
# SHOPIFY_STOREFRONT_LANGUAGE=en

# Optional recognition hints for the development catalog. Use public product
# and brand vocabulary only; do not put customer data here.
# ABLE_VOICE_KEYTERMS=Example Brand,Example Model,espresso grinder
# Optional Indian-English voice. Use a dedicated development Deepgram key only.
# DEEPGRAM_API_KEY=
# ABLE_VOICE_TTS_MODEL=flux-priya-en


# Authenticated order lookup through a least-authority development app.
# SHOPIFY_CLIENT_ID=replace-with-development-client-id
# SHOPIFY_CLIENT_SECRET=replace-with-development-client-secret

# Optional Customer Account API sign-in rail.
# SHOPIFY_CUSTOMER_CLIENT_ID=replace-with-development-customer-account-client-id
24 changes: 19 additions & 5 deletions apps/desk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@
},
"SHOPIFY_CUSTOMER_CLIENT_ID": {
"description": "Optional Customer Account API public client ID enabling store-account sign-in on the support portal."
},
"SHOPIFY_UCP_AGENT_PROFILE_URL": {
"description": "Optional public Able UCP profile URL for local parity; deployed agents derive /.well-known/ucp from the portal origin."
},
"SHOPIFY_STOREFRONT_COUNTRY": {
"description": "Optional ISO country code used to localize public Shopify prices and availability, such as IN."
},
"SHOPIFY_STOREFRONT_LANGUAGE": {
"description": "Optional BCP 47 language tag used to localize public storefront content, such as en or hi."
},
"ABLE_VOICE_KEYTERMS": {
"description": "Optional comma-separated product, brand, and place vocabulary boosted by voice recognition."
}
}
},
Expand All @@ -57,6 +69,7 @@
"build:portal-search": "node scripts/build-portal-search.mjs",
"build:assets": "npm run build:mcp-app && npm run build:voice-demo && npm run build:portal-search",
"dev": "npm run build:assets && wrangler dev",
"dev:parity": "npm run build:assets && node scripts/local-parity-server.mjs",
"deploy": "npm run build:assets && wrangler deploy",
"build:production-config": "node scripts/production-config.mjs",
"deploy:production": "node scripts/require-main-build.mjs && npm run build:production-config && wrangler d1 migrations apply DB --remote --config wrangler.production.generated.json && npm run build:assets && wrangler deploy --config wrangler.production.generated.json",
Expand All @@ -81,24 +94,25 @@
"@modelcontextprotocol/ext-apps": "^1.7.5",
"@modelcontextprotocol/sdk": "^1.29.0",
"agents": "0.20.1",
"ai": "7.0.37",
"hono": "^4.12.32",
"ai": "7.0.64",
"hono": "^4.13.1",
"marked": "^18.0.7",
"postal-mime": "^2.7.5",
"remend": "^1.3.0",
"workers-ai-provider": "4.0.0",
"xss": "^1.0.15",
"zod": "^4.4.3"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.18.8",
"@cloudflare/workers-types": "^5.20260719.1",
"@cloudflare/vitest-pool-workers": "^0.21.2",
"@cloudflare/workers-types": "^5.20260813.1",
"@playwright/test": "^1.62.0",
"@types/node": "^24.0.0",
"esbuild": "^0.28.1",
"jsonc-parser": "3.3.1",
"typescript": "^7.0.0",
"vitest": "^4.1.0",
"wrangler": "^4.112.0"
"wrangler": "^4.122.0"
},
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion apps/desk/public/voice-demo.css

Large diffs are not rendered by default.

157 changes: 121 additions & 36 deletions apps/desk/public/voice-demo.js

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions apps/desk/scripts/local-parity-seed.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
PRAGMA foreign_keys = ON;

-- Local-only readiness data for the browser assistant. These reserved example
-- addresses cannot deliver email and contain no tenant or customer data.
UPDATE workspace_settings
SET display_name = CASE
WHEN trim(display_name) = '' THEN 'Able Desk'
ELSE display_name
END,
portal_title = 'How can we help?',
support_email = 'support@example.test',
outbound_sender = 'support@example.test',
portal_base_url = 'http://127.0.0.1:8787',
public_intake_enabled = 1,
email_tested_at = '2026-01-01T00:00:00.000Z',
setup_completed_at = '2026-01-01T00:00:00.000Z',
updated_at = CURRENT_TIMESTAMP
WHERE id = 1;

INSERT INTO kb_sections
(id, slug, name, description, sort_order, created_at, updated_at)
VALUES
('local-parity-getting-started', 'getting-started', 'Getting started',
'Neutral local fixtures for exercising grounded assistant answers.', 10,
'2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z')
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
sort_order = excluded.sort_order,
updated_at = excluded.updated_at;

INSERT INTO kb_articles
(id, section_id, slug, title, body_markdown, excerpt, published, revision,
source_created_at, source_updated_at, created_at, updated_at)
VALUES
('local-parity-private-links', 'local-parity-getting-started',
'keep-your-private-request-link-safe', 'Keep your private request link safe',
'A private request link opens one support conversation. Do not forward it or paste it into a public channel. If it is lost, use **Find a request** with the same email address and case reference.',
'How to handle the private link for a support request.', 1,
'local-parity-rev-1', '2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z',
'2026-01-01T00:00:00.000Z')
ON CONFLICT(id) DO UPDATE SET
section_id = excluded.section_id,
slug = excluded.slug,
title = excluded.title,
body_markdown = excluded.body_markdown,
excerpt = excluded.excerpt,
published = excluded.published,
revision = excluded.revision,
source_updated_at = excluded.source_updated_at,
updated_at = excluded.updated_at;
67 changes: 67 additions & 0 deletions apps/desk/scripts/local-parity-server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { mkdirSync } from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const wrangler = process.platform === 'win32' ? 'wrangler.cmd' : 'wrangler'
const config = path.join(root, 'wrangler.jsonc')
const seed = path.join(root, 'scripts', 'local-parity-seed.sql')
const persistence = path.join(root, '.wrangler', 'local-parity')
const port = 8787

mkdirSync(persistence, { recursive: true })

function run(arguments_) {
return new Promise((resolve, reject) => {
const child = spawn(wrangler, arguments_, {
cwd: root,
env: process.env,
stdio: 'inherit',
})
child.once('error', reject)
child.once('exit', (code, signal) => {
if (code === 0) resolve()
else reject(new Error(`wrangler ${arguments_.join(' ')} exited with ${code ?? signal}`))
})
})
}

await run(['d1', 'migrations', 'apply', 'DB', '--local', '--persist-to', persistence, '--config', config])
await run(['d1', 'execute', 'DB', '--local', '--persist-to', persistence, '--file', seed, '--config', config])

console.log('Local production-like data is ready. Open http://127.0.0.1:8787/')
console.log('Text inference uses remote Workers AI; D1, R2, queues, Durable Objects, email, and customer data remain local.')
console.log('Streaming STT/TTS is unavailable through Wrangler remote bindings. Use a deployed staging or production Worker for microphone testing.')

const server = spawn(wrangler, [
'dev',
'--ip', '127.0.0.1',
'--port', String(port),
'--persist-to', persistence,
'--var', 'ABLE_DEV_EMAIL:owner@example.test',
'--var', 'ABLE_VOICE_DEMO_ENABLED:1',
'--var', 'ABLE_LOCAL_VOICE_UNAVAILABLE:1',
'--config', config,
], {
cwd: root,
env: process.env,
stdio: 'inherit',
})

function stop(signal) {
if (!server.killed) server.kill(signal)
}

process.on('SIGINT', () => stop('SIGINT'))
process.on('SIGTERM', () => stop('SIGTERM'))
server.once('error', (error) => {
console.error(error)
process.exitCode = 1
})
server.once('exit', (code, signal) => {
if (code && code !== 0) process.exitCode = code
else if (signal && !['SIGINT', 'SIGTERM'].includes(signal)) process.exitCode = 1
})
64 changes: 64 additions & 0 deletions apps/desk/scripts/run-voice-evals.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,70 @@ try {
console.log(`PASS support_scope: ${reply}`)
}

const hinglishResult = await turn(
[{ role: 'user', content: 'Mujhe home espresso ke liye machine chahiye. Warranty kaise kaam karti hai?' }],
null,
{
locale: 'en-IN',
timezone: 'Asia/Kolkata',
kb: {
articles: [{
title: 'Machine warranty',
content: 'Espresso machines include a one-year limited warranty. Keep the original invoice for a claim.',
}],
},
},
)
const hinglishReply = String(hinglishResult.text ?? '')
const hinglishTools = Array.isArray(hinglishResult.toolCalls) ? hinglishResult.toolCalls : []
assert(
hinglishTools.some((call) => call?.name === 'search_help_center'),
`Hinglish warranty question must search the help centre: ${JSON.stringify(hinglishResult)}`,
)
assert(
/warranty/i.test(hinglishReply) && /one.year|1.year|ek saal/i.test(hinglishReply),
`Hinglish answer must preserve the grounded warranty fact: ${hinglishReply}`,
)
assert(
/\b(?:aap|hai|ka|ki|ke|mein|liye|rakhiye|hogi|kar)\b/i.test(hinglishReply),
`Hinglish question should receive a natural Hinglish answer: ${hinglishReply}`,
)
assertNoRepeatedSentence(hinglishReply, 'Hinglish reply')
console.log(`PASS india_hinglish_grounding: ${hinglishReply}`)

const hinglishTroubleshooting = await turn(
[{ role: 'user', content: 'DF54 grinder clean karne ke baad start nahi ho raha. Kya check karoon?' }],
null,
{
locale: 'en-IN',
timezone: 'Asia/Kolkata',
kb: {
articles: [{
title: 'Grinder not working after cleaning? Check the reassembly',
content: 'Unplug the grinder. Remove and reinstall the upper burr carrier, aligning its marks before locking it into place. Then reinstall the hopper and try power again.',
}],
},
},
)
const troubleshootingReply = String(hinglishTroubleshooting.text ?? '')
const troubleshootingTools = Array.isArray(hinglishTroubleshooting.toolCalls)
? hinglishTroubleshooting.toolCalls
: []
assert(
troubleshootingTools.some((call) => call?.name === 'search_help_center'),
`Hinglish troubleshooting must search the help centre: ${JSON.stringify(hinglishTroubleshooting)}`,
)
assert(
/burr carrier|align|marks|reinstall/i.test(troubleshootingReply),
`Hinglish troubleshooting must use the returned reassembly step: ${troubleshootingReply}`,
)
assert(
!/no (?:direct )?guide|no information|koi (?:direct )?guide nahi|ticket (?:open|raise)/i.test(troubleshootingReply),
`A successful help result must not be described as missing: ${troubleshootingReply}`,
)
assertNoRepeatedSentence(troubleshootingReply, 'Hinglish troubleshooting reply')
console.log(`PASS india_hinglish_troubleshooting: ${troubleshootingReply}`)

const intakeHistory = []
const intakeResults = []
intakeResults.push(await converse(intakeHistory, 'Can you open a new support'))
Expand Down
Loading
Loading