mercury-agent 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +438 -0
- package/container/Dockerfile +127 -0
- package/container/Dockerfile.base +109 -0
- package/container/Dockerfile.power +17 -0
- package/container/agent-package.json +8 -0
- package/container/build.sh +54 -0
- package/docs/TODOS.md +147 -0
- package/docs/auth/dashboard.md +28 -0
- package/docs/auth/overview.md +109 -0
- package/docs/auth/whatsapp.md +173 -0
- package/docs/configuration.md +54 -0
- package/docs/container-lifecycle.md +349 -0
- package/docs/context-architecture.md +87 -0
- package/docs/deployment.md +199 -0
- package/docs/extensions.md +375 -0
- package/docs/graceful-shutdown.md +62 -0
- package/docs/kb-distillation.md +77 -0
- package/docs/media/overview.md +140 -0
- package/docs/media/whatsapp.md +171 -0
- package/docs/memory.md +137 -0
- package/docs/permissions.md +217 -0
- package/docs/pipeline.md +228 -0
- package/docs/prd-chat-memory.md +76 -0
- package/docs/prd-config-load.md +82 -0
- package/docs/rate-limiting.md +166 -0
- package/docs/scheduler.md +288 -0
- package/docs/setup-discord.md +100 -0
- package/docs/setup-slack.md +119 -0
- package/docs/setup-whatsapp.md +94 -0
- package/docs/subagents.md +166 -0
- package/docs/web-search.md +62 -0
- package/examples/extensions/README.md +12 -0
- package/examples/extensions/charts/index.ts +13 -0
- package/examples/extensions/charts/skill/SKILL.md +98 -0
- package/examples/extensions/gws/README.md +52 -0
- package/examples/extensions/gws/index.ts +106 -0
- package/examples/extensions/gws/skill/SKILL.md +57 -0
- package/examples/extensions/gws/skill/references/calendar.md +101 -0
- package/examples/extensions/gws/skill/references/docs.md +65 -0
- package/examples/extensions/gws/skill/references/drive.md +79 -0
- package/examples/extensions/gws/skill/references/gmail.md +85 -0
- package/examples/extensions/gws/skill/references/sheets.md +60 -0
- package/examples/extensions/napkin/index.ts +821 -0
- package/examples/extensions/napkin/prompts/consolidation-monthly.md +73 -0
- package/examples/extensions/napkin/prompts/consolidation-weekly.md +67 -0
- package/examples/extensions/napkin/prompts/kb-distillation.md +176 -0
- package/examples/extensions/napkin/skill/SKILL.md +728 -0
- package/examples/extensions/pdf/index.ts +23 -0
- package/examples/extensions/pdf/skill/LICENSE.txt +30 -0
- package/examples/extensions/pdf/skill/SKILL.md +314 -0
- package/examples/extensions/pdf/skill/forms.md +294 -0
- package/examples/extensions/pdf/skill/reference.md +612 -0
- package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -0
- package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -0
- package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -0
- package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -0
- package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -0
- package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -0
- package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -0
- package/examples/extensions/permission-guard/index.ts +65 -0
- package/examples/extensions/pinchtab/index.ts +199 -0
- package/examples/extensions/pinchtab/lib/session-injector.ts +144 -0
- package/examples/extensions/pinchtab/skill/SKILL.md +224 -0
- package/examples/extensions/pinchtab/skill/TRUST.md +69 -0
- package/examples/extensions/pinchtab/skill/references/api.md +297 -0
- package/examples/extensions/pinchtab/skill/references/env.md +45 -0
- package/examples/extensions/pinchtab/skill/references/profiles.md +107 -0
- package/examples/extensions/tradestation/host/refresh.ts +102 -0
- package/examples/extensions/tradestation/index.ts +153 -0
- package/examples/extensions/tradestation/skill/SKILL.md +67 -0
- package/examples/extensions/tradestation/skill/scripts/ts-cli.ts +111 -0
- package/examples/extensions/voice-synth/index.ts +94 -0
- package/examples/extensions/voice-synth/skill/SKILL.md +38 -0
- package/examples/extensions/voice-transcribe/index.ts +381 -0
- package/examples/extensions/voice-transcribe/requirements.txt +8 -0
- package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -0
- package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -0
- package/examples/extensions/web-search/index.ts +22 -0
- package/examples/extensions/web-search/skill/SKILL.md +114 -0
- package/examples/extensions/web-search/skill/references/apartments.md +178 -0
- package/examples/extensions/web-search/skill/references/car-purchase.md +132 -0
- package/examples/extensions/web-search/skill/references/car-rental.md +113 -0
- package/examples/extensions/web-search/skill/references/flights.md +133 -0
- package/examples/extensions/web-search/skill/references/hotels.md +148 -0
- package/examples/extensions/yahoo-mail/cli/bun.lock +66 -0
- package/examples/extensions/yahoo-mail/cli/package.json +13 -0
- package/examples/extensions/yahoo-mail/cli/ymail.mjs +353 -0
- package/examples/extensions/yahoo-mail/index.ts +57 -0
- package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -0
- package/package.json +106 -0
- package/resources/agents/explore.md +50 -0
- package/resources/agents/worker.md +24 -0
- package/resources/builtin-extensions.txt +3 -0
- package/resources/connection-env-vars.json +25 -0
- package/resources/extensions/.gitkeep +0 -0
- package/resources/pi-extensions/subagent/agents.ts +126 -0
- package/resources/pi-extensions/subagent/index.ts +964 -0
- package/resources/profiles/coding/AGENTS.md +43 -0
- package/resources/profiles/coding/mercury-profile.yaml +15 -0
- package/resources/profiles/general/AGENTS.md +31 -0
- package/resources/profiles/general/mercury-profile.yaml +15 -0
- package/resources/profiles/research/AGENTS.md +40 -0
- package/resources/profiles/research/mercury-profile.yaml +15 -0
- package/resources/skills/config/SKILL.md +25 -0
- package/resources/skills/context/SKILL.md +33 -0
- package/resources/skills/conversation-recap/SKILL.md +19 -0
- package/resources/skills/media/SKILL.md +27 -0
- package/resources/skills/mutes/SKILL.md +31 -0
- package/resources/skills/permissions/SKILL.md +19 -0
- package/resources/skills/preferences/SKILL.md +31 -0
- package/resources/skills/recall/SKILL.md +24 -0
- package/resources/skills/roles/SKILL.md +18 -0
- package/resources/skills/spaces/SKILL.md +18 -0
- package/resources/skills/tasks/SKILL.md +45 -0
- package/resources/templates/AGENTS.md +157 -0
- package/resources/templates/env.template +34 -0
- package/resources/templates/mercury.example.yaml +75 -0
- package/src/adapters/discord-native.ts +534 -0
- package/src/adapters/discord.ts +38 -0
- package/src/adapters/setup.ts +89 -0
- package/src/adapters/slack.ts +9 -0
- package/src/adapters/whatsapp-media.ts +337 -0
- package/src/adapters/whatsapp.ts +629 -0
- package/src/agent/api-socket.ts +127 -0
- package/src/agent/container-entry.ts +967 -0
- package/src/agent/container-error.ts +49 -0
- package/src/agent/container-runner.ts +1272 -0
- package/src/agent/model-capabilities-core.ts +23 -0
- package/src/agent/model-capabilities.ts +231 -0
- package/src/agent/pi-failure-class.ts +83 -0
- package/src/agent/pi-jsonl-parser.ts +306 -0
- package/src/agent/preferences-prompt.ts +20 -0
- package/src/agent/user-error-messages.ts +78 -0
- package/src/bridges/discord.ts +171 -0
- package/src/bridges/slack.ts +177 -0
- package/src/bridges/teams.ts +160 -0
- package/src/bridges/telegram.ts +571 -0
- package/src/bridges/whatsapp.ts +290 -0
- package/src/chat-shim.ts +259 -0
- package/src/cli/mercury.ts +2508 -0
- package/src/cli/mrctl-http.ts +27 -0
- package/src/cli/mrctl.ts +611 -0
- package/src/cli/whatsapp-auth.ts +260 -0
- package/src/config-file.ts +397 -0
- package/src/config-model-chain.ts +30 -0
- package/src/config.ts +316 -0
- package/src/core/api-types.ts +58 -0
- package/src/core/api.ts +105 -0
- package/src/core/commands.ts +76 -0
- package/src/core/conversation.ts +47 -0
- package/src/core/handler.ts +206 -0
- package/src/core/media.ts +200 -0
- package/src/core/mute-duration.ts +22 -0
- package/src/core/outbox.ts +76 -0
- package/src/core/permissions.ts +192 -0
- package/src/core/profiles.ts +245 -0
- package/src/core/rate-limiter.ts +127 -0
- package/src/core/router.ts +191 -0
- package/src/core/routes/chat.ts +172 -0
- package/src/core/routes/config-builtin.ts +107 -0
- package/src/core/routes/config.ts +81 -0
- package/src/core/routes/connections.ts +190 -0
- package/src/core/routes/console.ts +668 -0
- package/src/core/routes/control.ts +46 -0
- package/src/core/routes/conversations.ts +66 -0
- package/src/core/routes/dashboard.ts +2491 -0
- package/src/core/routes/extensions.ts +37 -0
- package/src/core/routes/index.ts +14 -0
- package/src/core/routes/media.ts +72 -0
- package/src/core/routes/messages.ts +37 -0
- package/src/core/routes/mutes.ts +89 -0
- package/src/core/routes/prefs.ts +95 -0
- package/src/core/routes/roles.ts +125 -0
- package/src/core/routes/spaces.ts +60 -0
- package/src/core/routes/storage.ts +126 -0
- package/src/core/routes/tasks.ts +189 -0
- package/src/core/routes/tradestation.ts +268 -0
- package/src/core/routes/tts.ts +51 -0
- package/src/core/runtime.ts +1140 -0
- package/src/core/space-queue.ts +103 -0
- package/src/core/storage-cleanup.ts +140 -0
- package/src/core/storage-guard.ts +24 -0
- package/src/core/task-scheduler.ts +132 -0
- package/src/core/telegram-format.ts +178 -0
- package/src/core/trigger.ts +142 -0
- package/src/dashboard/index.html +729 -0
- package/src/dashboard/tokens.css +53 -0
- package/src/extensions/api.ts +252 -0
- package/src/extensions/catalog.ts +117 -0
- package/src/extensions/config-registry.ts +83 -0
- package/src/extensions/context.ts +36 -0
- package/src/extensions/hooks.ts +156 -0
- package/src/extensions/image-builder.ts +617 -0
- package/src/extensions/installer.ts +306 -0
- package/src/extensions/jobs.ts +122 -0
- package/src/extensions/loader.ts +271 -0
- package/src/extensions/permission-guard.ts +52 -0
- package/src/extensions/reserved.ts +28 -0
- package/src/extensions/skills.ts +123 -0
- package/src/extensions/types.ts +462 -0
- package/src/logger.ts +174 -0
- package/src/main.ts +586 -0
- package/src/server.ts +391 -0
- package/src/storage/db.ts +1624 -0
- package/src/storage/memory.ts +45 -0
- package/src/storage/pi-auth.ts +95 -0
- package/src/text/markdown.ts +117 -0
- package/src/text/rtl.ts +38 -0
- package/src/tradestation/host-api.ts +77 -0
- package/src/tradestation/pending-orders.ts +69 -0
- package/src/tts/azure.ts +52 -0
- package/src/tts/google.ts +128 -0
- package/src/tts/index.ts +8 -0
- package/src/tts/language.ts +20 -0
- package/src/tts/synthesize.ts +133 -0
- package/src/types.ts +295 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const PI_SUBDIRS = [".pi", ".pi/extensions", ".pi/skills", ".pi/prompts"];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Ensure a pi resource directory exists with standard structure.
|
|
8
|
+
* Used for global, main, and shared groups root.
|
|
9
|
+
*/
|
|
10
|
+
export function ensurePiResourceDir(dir: string): void {
|
|
11
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
12
|
+
for (const sub of PI_SUBDIRS) {
|
|
13
|
+
fs.mkdirSync(path.join(dir, sub), { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Create empty AGENTS.md so pi discovers and loads from this directory
|
|
17
|
+
const agentsPath = path.join(dir, "AGENTS.md");
|
|
18
|
+
if (!fs.existsSync(agentsPath)) {
|
|
19
|
+
fs.writeFileSync(agentsPath, "", "utf8");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Ensure a per-space workspace exists with the pi resource structure.
|
|
25
|
+
* Vault structure is handled by extensions via workspace_init hooks.
|
|
26
|
+
*/
|
|
27
|
+
export function ensureSpaceWorkspace(
|
|
28
|
+
spacesDir: string,
|
|
29
|
+
spaceId: string,
|
|
30
|
+
): string {
|
|
31
|
+
const dir = path.join(spacesDir, spaceId);
|
|
32
|
+
|
|
33
|
+
ensurePiResourceDir(dir);
|
|
34
|
+
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Remove a space's workspace directory from disk.
|
|
40
|
+
* Safe to call if the directory doesn't exist (no-op).
|
|
41
|
+
*/
|
|
42
|
+
export function removeSpaceWorkspace(spacesDir: string, spaceId: string): void {
|
|
43
|
+
const dir = path.join(spacesDir, spaceId);
|
|
44
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
getOAuthApiKey,
|
|
5
|
+
type OAuthCredentials,
|
|
6
|
+
type OAuthProviderId,
|
|
7
|
+
} from "@mariozechner/pi-ai/oauth";
|
|
8
|
+
import { logger } from "../logger.js";
|
|
9
|
+
|
|
10
|
+
type AuthEntry =
|
|
11
|
+
| ({ type: "oauth" } & OAuthCredentials)
|
|
12
|
+
| { type: "api_key"; key: string }
|
|
13
|
+
| Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
type AuthFile = Record<string, AuthEntry>;
|
|
16
|
+
|
|
17
|
+
function readAuthFile(authPath: string): AuthFile {
|
|
18
|
+
if (!fs.existsSync(authPath)) return {};
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(authPath, "utf8")) as AuthFile;
|
|
21
|
+
} catch (err) {
|
|
22
|
+
logger.warn(
|
|
23
|
+
`pi-auth: auth file at ${authPath} is malformed, ignoring`,
|
|
24
|
+
err instanceof Error ? err : undefined,
|
|
25
|
+
);
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeAuthFile(authPath: string, auth: AuthFile): void {
|
|
31
|
+
fs.mkdirSync(path.dirname(authPath), { recursive: true });
|
|
32
|
+
fs.writeFileSync(authPath, JSON.stringify(auth, null, 2), "utf8");
|
|
33
|
+
fs.chmodSync(authPath, 0o600);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function getApiKeyFromPiAuthFile(options: {
|
|
37
|
+
provider: string;
|
|
38
|
+
authPath: string;
|
|
39
|
+
}): Promise<string | undefined> {
|
|
40
|
+
if (
|
|
41
|
+
process.env.MERCURY_ANTHROPIC_API_KEY ||
|
|
42
|
+
process.env.MERCURY_ANTHROPIC_OAUTH_TOKEN
|
|
43
|
+
) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (options.provider !== "anthropic") {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const authPath = options.authPath;
|
|
52
|
+
const auth = readAuthFile(authPath);
|
|
53
|
+
|
|
54
|
+
const entry = auth.anthropic;
|
|
55
|
+
if (!entry || typeof entry !== "object" || entry.type !== "oauth") {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const access = typeof entry.access === "string" ? entry.access : undefined;
|
|
60
|
+
const refresh = typeof entry.refresh === "string" ? entry.refresh : undefined;
|
|
61
|
+
const expires = typeof entry.expires === "number" ? entry.expires : undefined;
|
|
62
|
+
if (!access || !refresh || typeof expires !== "number") return undefined;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const result = await getOAuthApiKey("anthropic" satisfies OAuthProviderId, {
|
|
66
|
+
anthropic: {
|
|
67
|
+
access,
|
|
68
|
+
refresh,
|
|
69
|
+
expires,
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (!result) return undefined;
|
|
74
|
+
|
|
75
|
+
const nextAuth = {
|
|
76
|
+
...auth,
|
|
77
|
+
anthropic: {
|
|
78
|
+
type: "oauth" as const,
|
|
79
|
+
...result.newCredentials,
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
writeAuthFile(authPath, nextAuth);
|
|
84
|
+
logger.debug("Loaded anthropic oauth token from pi auth.json", {
|
|
85
|
+
authPath,
|
|
86
|
+
});
|
|
87
|
+
return result.apiKey;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
logger.warn(
|
|
90
|
+
"Failed to load anthropic oauth token from pi auth.json",
|
|
91
|
+
error instanceof Error ? error : undefined,
|
|
92
|
+
);
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Outbound markdown normalization for chat platforms (WhatsApp + Telegram plain
|
|
2
|
+
// text). WhatsApp and Telegram-without-rich-formatting render only a restricted
|
|
3
|
+
// lightweight markup — GitHub-flavored markdown tables, horizontal rules, and
|
|
4
|
+
// `**double-star bold**` all leak through as literal characters and look broken
|
|
5
|
+
// on mobile. The space `AGENTS.md` bans these in prose, but a prose rule relies
|
|
6
|
+
// on the model complying; this turns the ban into a deterministic send-side gate
|
|
7
|
+
// (same pattern as `applyRtlDirection`). Apply BEFORE `applyRtlDirection` — this
|
|
8
|
+
// rewrites line structure (tables expand to several bullet lines, rules collapse
|
|
9
|
+
// to blank lines), and the RLM prefixing must run on the final lines.
|
|
10
|
+
//
|
|
11
|
+
// Send-side only. Do NOT call from inbound parsing, storage, the console UI, or
|
|
12
|
+
// the Telegram rich-HTML path (`markdownToTelegramHtml` owns formatting there;
|
|
13
|
+
// normalizing `**`→`*` before it would turn bold into italic).
|
|
14
|
+
|
|
15
|
+
// A horizontal rule: a line of only `-`, `*`, or `_` (3+), optionally space-
|
|
16
|
+
// separated (`---`, `***`, `___`, `- - -`). Table separator rows contain `|` so
|
|
17
|
+
// they never match here.
|
|
18
|
+
const HR_LINE = /^\s*(?:-\s*){3,}$|^\s*(?:\*\s*){3,}$|^\s*(?:_\s*){3,}$/;
|
|
19
|
+
|
|
20
|
+
// A markdown table separator row: only `|`, `-`, `:`, and whitespace, and it must
|
|
21
|
+
// contain both a pipe and a dash (e.g. `|---|---|`, `| :-- | --: |`).
|
|
22
|
+
const TABLE_SEPARATOR = /^\s*\|?[\s:|-]*-[\s:|-]*\|[\s:|-]*$/;
|
|
23
|
+
|
|
24
|
+
// A line that participates in a table: contains at least one pipe.
|
|
25
|
+
const TABLE_ROW = /\|/;
|
|
26
|
+
|
|
27
|
+
const FENCE = /^\s*```/;
|
|
28
|
+
|
|
29
|
+
/** Split a markdown table row into trimmed, non-empty cell strings. */
|
|
30
|
+
function tableCells(line: string): string[] {
|
|
31
|
+
return line
|
|
32
|
+
.split("|")
|
|
33
|
+
.map((c) => c.trim())
|
|
34
|
+
.filter((c) => c.length > 0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Normalize markdown bold/bold-italic delimiters to WhatsApp's single-asterisk
|
|
38
|
+
// `*bold*`. Matches runs of 2–3 markers (`**x**`, `***x***`, `__x__`, `___x___`)
|
|
39
|
+
// so triple-star bold-italic collapses cleanly rather than leaving a stray `**`.
|
|
40
|
+
// The capture forbids the marker char so a match can't span across a separate
|
|
41
|
+
// emphasis pair. Single `*x*` / `_x_` (WhatsApp bold / italic) are left intact.
|
|
42
|
+
// Note: a `__identifier__` in plain prose (outside a code fence) is also rewritten
|
|
43
|
+
// — an acceptable trade-off, as chat replies rarely contain bare code tokens.
|
|
44
|
+
function normalizeBold(line: string): string {
|
|
45
|
+
return line
|
|
46
|
+
.replace(/\*{2,3}([^*\n]+?)\*{2,3}/g, "*$1*")
|
|
47
|
+
.replace(/_{2,3}([^_\n]+?)_{2,3}/g, "*$1*");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Rewrite markdown that does not render on WhatsApp / plain Telegram into the
|
|
52
|
+
* supported lightweight markup:
|
|
53
|
+
* - GFM tables → `•` bullet lines (one per row, cells joined with ` — `)
|
|
54
|
+
* - horizontal rules (`---` / `***` / `___`) → a blank line
|
|
55
|
+
* - `**bold**` / `__bold__` → `*bold*`
|
|
56
|
+
*
|
|
57
|
+
* Fenced code blocks (```` ``` ````) are passed through untouched — WhatsApp
|
|
58
|
+
* renders them as monospace, and their contents are not markdown.
|
|
59
|
+
*
|
|
60
|
+
* No-op on empty input.
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeChatMarkdown(text: string): string {
|
|
63
|
+
if (!text) return text;
|
|
64
|
+
|
|
65
|
+
const lines = text.split("\n");
|
|
66
|
+
const out: string[] = [];
|
|
67
|
+
let inFence = false;
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < lines.length; i++) {
|
|
70
|
+
const line = lines[i];
|
|
71
|
+
|
|
72
|
+
if (FENCE.test(line)) {
|
|
73
|
+
inFence = !inFence;
|
|
74
|
+
out.push(line);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (inFence) {
|
|
78
|
+
out.push(line);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Table block: a row with a pipe immediately followed by a separator row.
|
|
83
|
+
// Requiring the separator row keeps us from mangling prose/code that merely
|
|
84
|
+
// contains a `|` (GFM requires a separator row for a real table).
|
|
85
|
+
if (
|
|
86
|
+
TABLE_ROW.test(line) &&
|
|
87
|
+
i + 1 < lines.length &&
|
|
88
|
+
TABLE_SEPARATOR.test(lines[i + 1])
|
|
89
|
+
) {
|
|
90
|
+
// Header row → bullet.
|
|
91
|
+
const header = tableCells(line);
|
|
92
|
+
if (header.length > 0) out.push(`• ${header.join(" — ")}`);
|
|
93
|
+
// Skip the separator row, then consume contiguous data rows.
|
|
94
|
+
let j = i + 2;
|
|
95
|
+
while (
|
|
96
|
+
j < lines.length &&
|
|
97
|
+
TABLE_ROW.test(lines[j]) &&
|
|
98
|
+
!TABLE_SEPARATOR.test(lines[j])
|
|
99
|
+
) {
|
|
100
|
+
const cells = tableCells(lines[j]);
|
|
101
|
+
if (cells.length > 0) out.push(`• ${cells.join(" — ")}`);
|
|
102
|
+
j++;
|
|
103
|
+
}
|
|
104
|
+
i = j - 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (HR_LINE.test(line)) {
|
|
109
|
+
out.push("");
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
out.push(normalizeBold(line));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return out.join("\n");
|
|
117
|
+
}
|
package/src/text/rtl.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// U+200F RIGHT-TO-LEFT MARK. Invisible bidi control that forces RTL paragraph
|
|
2
|
+
// direction in WhatsApp, Telegram, and any client implementing the Unicode Bidi
|
|
3
|
+
// Algorithm. Per-line application matches the empirically-proven n8n/Telegram
|
|
4
|
+
// pattern: each newline-separated line is treated as its own bidi paragraph.
|
|
5
|
+
const RLM = "";
|
|
6
|
+
|
|
7
|
+
// `\p{Bidi_Class=R/AL}` is NOT supported by V8 (Bun 1.3.10 throws SyntaxError at
|
|
8
|
+
// parse time). `\p{Script=...}` is supported and covers every commonly-used RTL
|
|
9
|
+
// script including all of Arabic's extension/supplement/presentation-form blocks
|
|
10
|
+
// via the Unicode Script property.
|
|
11
|
+
const HAS_RTL =
|
|
12
|
+
/[\p{Script=Hebrew}\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Mandaic}\p{Script=Samaritan}]/u;
|
|
13
|
+
|
|
14
|
+
// Strip leading RLM (U+200F), LRE (U+202A), and RLE (U+202B). The existing
|
|
15
|
+
// `whatsapp-formatting` skill prepends RLE; leaving it in place under our RLM
|
|
16
|
+
// would nest two bidi controls and behave unpredictably on older Android clients.
|
|
17
|
+
const LEADING_BIDI_CTRL = /^[]+/;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Force correct RTL paragraph alignment for outbound chat messages.
|
|
21
|
+
*
|
|
22
|
+
* If `text` contains any RTL strong character (Hebrew, Arabic and all its blocks,
|
|
23
|
+
* Syriac, Thaana, N'Ko, Mandaic, Samaritan), prefix every line with exactly one
|
|
24
|
+
* U+200F. Idempotent: any pre-existing leading run of bidi controls on a line is
|
|
25
|
+
* stripped first.
|
|
26
|
+
*
|
|
27
|
+
* No-op on purely LTR input — returned byte-identical.
|
|
28
|
+
*
|
|
29
|
+
* Send-side only. Do NOT call from inbound message parsing, storage, or any
|
|
30
|
+
* console UI render path.
|
|
31
|
+
*/
|
|
32
|
+
export function applyRtlDirection(text: string): string {
|
|
33
|
+
if (!text || !HAS_RTL.test(text)) return text;
|
|
34
|
+
return text
|
|
35
|
+
.split("\n")
|
|
36
|
+
.map((line) => RLM + line.replace(LEADING_BIDI_CTRL, ""))
|
|
37
|
+
.join("\n");
|
|
38
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side TradeStation REST v3 calls using tokens in extension_state.
|
|
3
|
+
* Used by core API routes; keeps order placement off the agent container.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Db } from "../storage/db.js";
|
|
7
|
+
|
|
8
|
+
export const TRADESTATION_EXT = "tradestation";
|
|
9
|
+
|
|
10
|
+
export function tradeStationApiBase(): string {
|
|
11
|
+
const raw = process.env.MERCURY_TS_API_BASE?.trim();
|
|
12
|
+
if (raw) return raw.replace(/\/$/, "");
|
|
13
|
+
return "https://api.tradestation.com/v3";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type TradeStationFetch = typeof fetch;
|
|
17
|
+
|
|
18
|
+
export async function tradeStationAuthorizedJson(
|
|
19
|
+
db: Db,
|
|
20
|
+
init: {
|
|
21
|
+
method: "GET" | "POST" | "PUT" | "DELETE";
|
|
22
|
+
path: string;
|
|
23
|
+
body?: unknown;
|
|
24
|
+
},
|
|
25
|
+
fetchImpl: TradeStationFetch = fetch,
|
|
26
|
+
): Promise<{ ok: boolean; status: number; data: unknown }> {
|
|
27
|
+
const token = db.getExtState(TRADESTATION_EXT, "access_token");
|
|
28
|
+
const authErr = db.getExtState(TRADESTATION_EXT, "auth_error");
|
|
29
|
+
if (authErr) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
status: 401,
|
|
33
|
+
data: { error: "TradeStation auth error", code: authErr },
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (!token) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
status: 401,
|
|
40
|
+
data: {
|
|
41
|
+
error:
|
|
42
|
+
"TradeStation access token missing — configure OAuth on the Mercury host",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const base = tradeStationApiBase();
|
|
48
|
+
const url = new URL(init.path.replace(/^\//, ""), `${base}/`);
|
|
49
|
+
const headers: Record<string, string> = {
|
|
50
|
+
Authorization: `Bearer ${token}`,
|
|
51
|
+
Accept: "application/json",
|
|
52
|
+
};
|
|
53
|
+
let body: string | undefined;
|
|
54
|
+
if (init.body !== undefined) {
|
|
55
|
+
headers["Content-Type"] = "application/json";
|
|
56
|
+
body = JSON.stringify(init.body);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const res = await fetchImpl(url.toString(), {
|
|
60
|
+
method: init.method,
|
|
61
|
+
headers,
|
|
62
|
+
body,
|
|
63
|
+
});
|
|
64
|
+
const text = await res.text();
|
|
65
|
+
let data: unknown;
|
|
66
|
+
try {
|
|
67
|
+
data = text ? JSON.parse(text) : null;
|
|
68
|
+
} catch {
|
|
69
|
+
data = { raw: text };
|
|
70
|
+
}
|
|
71
|
+
return { ok: res.ok, status: res.status, data };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** TradeStation SIM accounts use an AccountID prefix convention (see TS docs). */
|
|
75
|
+
export function isLikelySimAccount(accountId: string): boolean {
|
|
76
|
+
return /^SIM/i.test(accountId.trim());
|
|
77
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { Db } from "../storage/db.js";
|
|
3
|
+
import { TRADESTATION_EXT } from "./host-api.js";
|
|
4
|
+
|
|
5
|
+
export const PENDING_ORDER_PREFIX = "pending_order:";
|
|
6
|
+
export const PENDING_ORDER_TTL_MS = 15 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
/** Exact JSON body sent to TradeStation confirm/place. */
|
|
9
|
+
export type TradeStationOrderRequestJson = Record<string, unknown>;
|
|
10
|
+
|
|
11
|
+
export interface PendingOrderRecord {
|
|
12
|
+
v: 1;
|
|
13
|
+
spaceId: string;
|
|
14
|
+
callerId: string;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
expiresAt: number;
|
|
17
|
+
orderRequest: TradeStationOrderRequestJson;
|
|
18
|
+
/** Short text for user-facing summary */
|
|
19
|
+
summary: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function cleanupExpiredTradestationPending(db: Db): void {
|
|
23
|
+
const rows = db.listExtState(TRADESTATION_EXT);
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
for (const { key, value } of rows) {
|
|
26
|
+
if (!key.startsWith(PENDING_ORDER_PREFIX)) continue;
|
|
27
|
+
try {
|
|
28
|
+
const rec = JSON.parse(value) as PendingOrderRecord;
|
|
29
|
+
if (rec.expiresAt < now) {
|
|
30
|
+
db.deleteExtState(TRADESTATION_EXT, key);
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
db.deleteExtState(TRADESTATION_EXT, key);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createPendingOrderId(): string {
|
|
39
|
+
return randomUUID();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function pendingOrderKey(id: string): string {
|
|
43
|
+
return `${PENDING_ORDER_PREFIX}${id}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function savePendingOrder(
|
|
47
|
+
db: Db,
|
|
48
|
+
id: string,
|
|
49
|
+
record: PendingOrderRecord,
|
|
50
|
+
): void {
|
|
51
|
+
db.setExtState(TRADESTATION_EXT, pendingOrderKey(id), JSON.stringify(record));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function loadPendingOrder(
|
|
55
|
+
db: Db,
|
|
56
|
+
id: string,
|
|
57
|
+
): PendingOrderRecord | null {
|
|
58
|
+
const raw = db.getExtState(TRADESTATION_EXT, pendingOrderKey(id));
|
|
59
|
+
if (!raw) return null;
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(raw) as PendingOrderRecord;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function deletePendingOrder(db: Db, id: string): void {
|
|
68
|
+
db.deleteExtState(TRADESTATION_EXT, pendingOrderKey(id));
|
|
69
|
+
}
|
package/src/tts/azure.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { TtsLanguage } from "./language.js";
|
|
2
|
+
|
|
3
|
+
const AZURE_VOICES: Record<TtsLanguage, string> = {
|
|
4
|
+
"he-IL": "he-IL-HilaNeural",
|
|
5
|
+
"en-US": "en-US-JennyNeural",
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/** MP3 128 kbps mono 16 kHz — widely compatible for chat attachments. */
|
|
9
|
+
const OUTPUT_FORMAT = "audio-16khz-128kbitrate-mono-mp3";
|
|
10
|
+
|
|
11
|
+
function escapeXml(text: string): string {
|
|
12
|
+
return text
|
|
13
|
+
.replace(/&/g, "&")
|
|
14
|
+
.replace(/</g, "<")
|
|
15
|
+
.replace(/>/g, ">")
|
|
16
|
+
.replace(/"/g, """)
|
|
17
|
+
.replace(/'/g, "'");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function synthesizeAzure(opts: {
|
|
21
|
+
key: string;
|
|
22
|
+
region: string;
|
|
23
|
+
text: string;
|
|
24
|
+
language: TtsLanguage;
|
|
25
|
+
}): Promise<Buffer> {
|
|
26
|
+
const voiceName = AZURE_VOICES[opts.language];
|
|
27
|
+
const ssml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
28
|
+
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="${opts.language}">
|
|
29
|
+
<voice name="${voiceName}">${escapeXml(opts.text)}</voice>
|
|
30
|
+
</speak>`;
|
|
31
|
+
|
|
32
|
+
const url = `https://${opts.region.trim()}.tts.speech.microsoft.com/cognitiveservices/v1`;
|
|
33
|
+
const res = await fetch(url, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: {
|
|
36
|
+
"Content-Type": "application/ssml+xml",
|
|
37
|
+
"X-Microsoft-OutputFormat": OUTPUT_FORMAT,
|
|
38
|
+
"Ocp-Apim-Subscription-Key": opts.key.trim(),
|
|
39
|
+
"User-Agent": "mercury-agent-tts",
|
|
40
|
+
},
|
|
41
|
+
body: ssml,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const errText = await res.text().catch(() => "");
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Azure TTS HTTP ${res.status}: ${errText.slice(0, 500) || res.statusText}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return Buffer.from(await res.arrayBuffer());
|
|
52
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createSign, randomBytes } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import type { TtsLanguage } from "./language.js";
|
|
4
|
+
|
|
5
|
+
const GOOGLE_VOICES: Record<TtsLanguage, string> = {
|
|
6
|
+
"he-IL": "he-IL-Standard-A",
|
|
7
|
+
"en-US": "en-US-Neural2-C",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
interface ServiceAccountJson {
|
|
11
|
+
client_email?: string;
|
|
12
|
+
private_key?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function base64url(data: string | Buffer): string {
|
|
16
|
+
const buf = typeof data === "string" ? Buffer.from(data) : data;
|
|
17
|
+
return buf
|
|
18
|
+
.toString("base64")
|
|
19
|
+
.replace(/\+/g, "-")
|
|
20
|
+
.replace(/\//g, "_")
|
|
21
|
+
.replace(/=+$/u, "");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function fetchGoogleAccessToken(credsPath: string): Promise<string> {
|
|
25
|
+
let raw: ServiceAccountJson;
|
|
26
|
+
try {
|
|
27
|
+
raw = JSON.parse(readFileSync(credsPath, "utf-8")) as ServiceAccountJson;
|
|
28
|
+
} catch (e) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Invalid Google service account JSON: ${e instanceof Error ? e.message : String(e)}`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const email = raw.client_email?.trim();
|
|
34
|
+
const privateKey = raw.private_key?.trim();
|
|
35
|
+
if (!email || !privateKey) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"Google service account JSON missing client_email or private_key",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const now = Math.floor(Date.now() / 1000);
|
|
42
|
+
const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
43
|
+
const payload = base64url(
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
iss: email,
|
|
46
|
+
scope: "https://www.googleapis.com/auth/cloud-platform",
|
|
47
|
+
aud: "https://oauth2.googleapis.com/token",
|
|
48
|
+
iat: now,
|
|
49
|
+
exp: now + 3600,
|
|
50
|
+
jti: base64url(randomBytes(16)),
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
const signInput = `${header}.${payload}`;
|
|
54
|
+
const sign = createSign("RSA-SHA256");
|
|
55
|
+
sign.update(signInput);
|
|
56
|
+
sign.end();
|
|
57
|
+
const sig = base64url(sign.sign(privateKey));
|
|
58
|
+
const jwt = `${signInput}.${sig}`;
|
|
59
|
+
|
|
60
|
+
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
63
|
+
body: new URLSearchParams({
|
|
64
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
65
|
+
assertion: jwt,
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const tokenJson = (await tokenRes.json()) as {
|
|
70
|
+
access_token?: string;
|
|
71
|
+
error?: string;
|
|
72
|
+
error_description?: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
if (!tokenRes.ok || !tokenJson.access_token) {
|
|
76
|
+
const msg =
|
|
77
|
+
tokenJson.error_description ||
|
|
78
|
+
tokenJson.error ||
|
|
79
|
+
`HTTP ${tokenRes.status}`;
|
|
80
|
+
throw new Error(`Google OAuth token failed: ${msg}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return tokenJson.access_token;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function synthesizeGoogle(opts: {
|
|
87
|
+
credentialsPath: string;
|
|
88
|
+
text: string;
|
|
89
|
+
language: TtsLanguage;
|
|
90
|
+
}): Promise<Buffer> {
|
|
91
|
+
const accessToken = await fetchGoogleAccessToken(opts.credentialsPath);
|
|
92
|
+
const voiceName = GOOGLE_VOICES[opts.language];
|
|
93
|
+
|
|
94
|
+
const body = {
|
|
95
|
+
input: { text: opts.text },
|
|
96
|
+
voice: {
|
|
97
|
+
languageCode: opts.language,
|
|
98
|
+
name: voiceName,
|
|
99
|
+
},
|
|
100
|
+
audioConfig: {
|
|
101
|
+
audioEncoding: "MP3",
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const res = await fetch(
|
|
106
|
+
"https://texttospeech.googleapis.com/v1/text:synthesize",
|
|
107
|
+
{
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
Authorization: `Bearer ${accessToken}`,
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify(body),
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const data = (await res.json()) as {
|
|
118
|
+
audioContent?: string;
|
|
119
|
+
error?: { message?: string; code?: number };
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (!res.ok || !data.audioContent) {
|
|
123
|
+
const msg = data.error?.message || `HTTP ${res.status}`;
|
|
124
|
+
throw new Error(`Google TTS failed: ${msg}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return Buffer.from(data.audioContent, "base64");
|
|
128
|
+
}
|
package/src/tts/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** BCP-47 locales supported for default voices. */
|
|
2
|
+
export type TtsLanguage = "he-IL" | "en-US";
|
|
3
|
+
|
|
4
|
+
export type TtsLanguageInput = "auto" | TtsLanguage;
|
|
5
|
+
|
|
6
|
+
const HEBREW_RE = /\p{Script=Hebrew}/u;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve `auto` using Hebrew script detection; otherwise pass through.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveTtsLanguageFromText(
|
|
12
|
+
text: string,
|
|
13
|
+
input: TtsLanguageInput | undefined,
|
|
14
|
+
): TtsLanguage {
|
|
15
|
+
const mode = input ?? "auto";
|
|
16
|
+
if (mode === "auto") {
|
|
17
|
+
return HEBREW_RE.test(text) ? "he-IL" : "en-US";
|
|
18
|
+
}
|
|
19
|
+
return mode;
|
|
20
|
+
}
|