426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
1018 lines
35 KiB
JavaScript
1018 lines
35 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* `translate-action-examples` — bulk-translation harness for ActionExamples.
|
|
*
|
|
* Wires the existing `MultilingualPromptRegistry` (W2-E,
|
|
* `plugins/plugin-personal-assistant/src/lifeops/i18n/prompt-registry.ts`) into a CLI flow
|
|
* that:
|
|
* 1. Loads a TypeScript action file as text and extracts its
|
|
* `examples: ActionExample[][]` array via static AST parsing (no module
|
|
* load — the action graph would drag the whole runtime in).
|
|
* 2. For each English example pair, calls Cerebras `gpt-oss-120b` with a
|
|
* strict translation prompt that returns JSON of the same shape.
|
|
* 3. Emits a TypeScript source fragment registering the translations onto
|
|
* the registry, keyed `<actionName>.<index>:<locale>`.
|
|
*
|
|
* The harness is the proof-of-concept path. The Phase-3 sample translations
|
|
* land as a generated `i18n/generated/<action>.<locale>.ts` file imported by
|
|
* the registry's default-pack loader. The action's own `examples` field stays
|
|
* English-canonical.
|
|
*
|
|
* Usage:
|
|
* bun plugins/plugin-personal-assistant/scripts/translate-action-examples.mjs \
|
|
* plugins/plugin-personal-assistant/src/actions/life.ts \
|
|
* --target-locale=es \
|
|
* --provider=cerebras \
|
|
* --max-examples=3 \
|
|
* --action-name=life \
|
|
* --output=plugins/plugin-personal-assistant/src/lifeops/i18n/generated/life.es.ts
|
|
*
|
|
* Environment:
|
|
* CEREBRAS_API_KEY — required when --provider=cerebras
|
|
* CEREBRAS_BASE_URL — defaults to https://api.cerebras.ai/v1
|
|
* CEREBRAS_MODEL — defaults to gpt-oss-120b
|
|
*
|
|
* Failure mode: bad LLM JSON, network errors, missing keys all throw and
|
|
* exit non-zero. No silent fallback. The harness is loud by design.
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import dotenv from "dotenv";
|
|
import { Project, SyntaxKind } from "ts-morph";
|
|
|
|
const SUPPORTED_LOCALES = new Set(["es", "fr", "ja"]);
|
|
const DEFAULT_MODEL = "gpt-oss-120b";
|
|
const DEFAULT_BASE_URL = "https://api.cerebras.ai/v1";
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const packageRoot = path.resolve(here, "..");
|
|
const elizaRoot = path.resolve(packageRoot, "..", "..");
|
|
for (const candidate of [
|
|
path.join(packageRoot, ".env"),
|
|
path.join(elizaRoot, ".env"),
|
|
]) {
|
|
if (fs.existsSync(candidate)) {
|
|
dotenv.config({ path: candidate, override: false });
|
|
}
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const args = argv.slice(2);
|
|
if (args.length === 0) {
|
|
fail(
|
|
"Usage: translate-action-examples.mjs <action-file> --target-locale=<locale> [--provider=cerebras] [--max-examples=N] [--action-name=NAME] [--output=PATH] [--dry-run]",
|
|
);
|
|
}
|
|
const result = {
|
|
file: args[0],
|
|
targetLocales: [],
|
|
provider: "cerebras",
|
|
maxExamples: Number.POSITIVE_INFINITY,
|
|
actionName: null,
|
|
outputPath: null,
|
|
dryRun: false,
|
|
};
|
|
for (const arg of args.slice(1)) {
|
|
if (arg === "--dry-run") {
|
|
result.dryRun = true;
|
|
} else if (arg.startsWith("--target-locale=")) {
|
|
result.targetLocales = arg
|
|
.slice("--target-locale=".length)
|
|
.split(",")
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
} else if (arg.startsWith("--provider=")) {
|
|
result.provider = arg.slice("--provider=".length);
|
|
} else if (arg.startsWith("--max-examples=")) {
|
|
result.maxExamples = Number(arg.slice("--max-examples=".length));
|
|
} else if (arg.startsWith("--action-name=")) {
|
|
result.actionName = arg.slice("--action-name=".length);
|
|
} else if (arg.startsWith("--output=")) {
|
|
result.outputPath = arg.slice("--output=".length);
|
|
} else {
|
|
fail(`Unknown arg: ${arg}`);
|
|
}
|
|
}
|
|
if (result.targetLocales.length === 0) {
|
|
fail("--target-locale=<locale> is required (e.g. --target-locale=es)");
|
|
}
|
|
for (const locale of result.targetLocales) {
|
|
if (!SUPPORTED_LOCALES.has(locale)) {
|
|
fail(
|
|
`Locale "${locale}" not supported. Supported: ${[...SUPPORTED_LOCALES].join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
if (result.provider !== "cerebras") {
|
|
fail(`Provider "${result.provider}" not supported. Supported: cerebras`);
|
|
}
|
|
if (!Number.isFinite(result.maxExamples) || result.maxExamples <= 0) {
|
|
if (result.maxExamples !== Number.POSITIVE_INFINITY) {
|
|
fail("--max-examples must be a positive integer");
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function fail(msg) {
|
|
console.error(`[translate-action-examples] ${msg}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
/**
|
|
* Extract the action `name` literal and the `examples` array from a TS file.
|
|
* We use ts-morph to walk the AST so we don't have to evaluate the module
|
|
* (action files import the entire runtime; loading them in a pure-Node script
|
|
* is not feasible).
|
|
*
|
|
* Resolution strategies (each tried in order on the `examples:` initializer):
|
|
*
|
|
* 1. **Inline array literal.** `examples: [[user, agent], ...]` — read
|
|
* directly off the initializer. Fast path.
|
|
* 2. **`as ActionExample[][]` cast.** Same shape wrapped in an
|
|
* `AsExpression`; we unwrap and re-classify the inner expression.
|
|
* 3. **Identifier reference.** `examples: SOMETHING_EXAMPLES` —
|
|
* `getDefinitionNodes()` resolves the identifier (in-file or
|
|
* cross-file via re-exports/imports) to its `VariableDeclaration`
|
|
* initializer, which is then re-classified.
|
|
* 4. **Spread + concatenation.** `examples: [...A_EXAMPLES,
|
|
* ...someAction.examples ?? [], inlinePair]` — each child is resolved
|
|
* independently via the same strategies and the resulting pair-array
|
|
* nodes are concatenated. `??` short-circuits: we recurse on the
|
|
* left-hand side.
|
|
* 5. **Property access.** `someAction.examples` — resolve `someAction` to
|
|
* its declaration, find its inner `examples` property, recurse.
|
|
*
|
|
* Resolution always returns AST `ArrayLiteralExpression` nodes so the
|
|
* existing pair parser (`parseExamplePair`) keeps working unchanged. If a
|
|
* step cannot resolve to a concrete array, the harness fails loud with the
|
|
* exact source location it stalled on (CLAUDE.md no-silent-fallback rule).
|
|
*/
|
|
function extractFromActionFile(filePath, actionNameOverride) {
|
|
// Use the package's tsconfig so cross-file Identifier resolution works
|
|
// (`getDefinitionNodes()` needs the target source file in the project, and
|
|
// resolving `import { X } from "./Y"` requires the compiler to know the
|
|
// module-resolution root).
|
|
const project = new Project({
|
|
skipAddingFilesFromTsConfig: true,
|
|
compilerOptions: {
|
|
allowJs: false,
|
|
// baseUrl + module/target picked to match the action source files; we
|
|
// only need symbol/identifier resolution, not emit.
|
|
target: 99, // ESNext
|
|
module: 99, // ESNext
|
|
moduleResolution: 2, // NodeJs / Node10
|
|
allowImportingTsExtensions: true,
|
|
noEmit: true,
|
|
},
|
|
});
|
|
const sourceFile = project.addSourceFileAtPath(filePath);
|
|
|
|
// Locate the action's `examples:` property assignment + capture the
|
|
// surrounding object literal (so we can read the `name` field from the
|
|
// SAME literal — avoids matching a parameter schema's `name`/`examples`).
|
|
const found = locateExamplesPropertyAssignment(sourceFile);
|
|
|
|
// If we still haven't located a property assignment, fall back to a
|
|
// top-level `const examples = [...]` (the legacy strategy 1 shape — used by
|
|
// ~20 app-lifeops actions where `examples` is hoisted above the `Action`
|
|
// literal).
|
|
let examplesInitializer = found?.initializer ?? null;
|
|
const actionLiteral = found?.actionLiteral ?? null;
|
|
if (!examplesInitializer) {
|
|
for (const stmt of sourceFile.getVariableStatements()) {
|
|
for (const decl of stmt.getDeclarations()) {
|
|
if (decl.getName() === "examples") {
|
|
const init = decl.getInitializer();
|
|
if (init) {
|
|
examplesInitializer = init;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (examplesInitializer) break;
|
|
}
|
|
}
|
|
|
|
if (!examplesInitializer) {
|
|
fail(`Could not locate an 'examples' property in ${filePath}`);
|
|
}
|
|
|
|
// Resolve the initializer to a flat list of pair-array AST nodes,
|
|
// following Identifiers, spread elements, ??-fallbacks, and PropertyAccess
|
|
// chains. The resolver throws via `fail()` on anything it can't reduce to
|
|
// a concrete array literal — the harness has no silent fallback.
|
|
const pairArrayNodes = resolveToPairArrayNodes(
|
|
examplesInitializer,
|
|
project,
|
|
sourceFile,
|
|
new Set(),
|
|
);
|
|
|
|
// Extract action name from the SAME object literal as the examples
|
|
// property. Fall back to scanning sibling Action literals only if needed.
|
|
// Accept either a string literal or an Identifier (e.g. `name: ACTION_NAME`)
|
|
// — the latter is resolved via the same identifier-resolution path used
|
|
// for examples.
|
|
let actionName = actionNameOverride;
|
|
if (!actionName && actionLiteral) {
|
|
for (const prop of actionLiteral.getProperties()) {
|
|
if (prop.getKind() !== SyntaxKind.PropertyAssignment) continue;
|
|
const nameNode = prop.getNameNode?.();
|
|
if (nameNode?.getText() !== "name") continue;
|
|
const init = prop.getInitializer?.();
|
|
if (!init) continue;
|
|
const resolved = resolveStringValue(init, project);
|
|
if (resolved !== null) {
|
|
actionName = resolved;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if (!actionName) {
|
|
// Top-level `examples` const + separate Action literal in the same file.
|
|
sourceFile.forEachDescendant((node) => {
|
|
if (actionName) return;
|
|
if (node.getKind() !== SyntaxKind.ObjectLiteralExpression) return;
|
|
const obj = node;
|
|
let candidateName = null;
|
|
let looksLikeAction = false;
|
|
for (const prop of obj.getProperties()) {
|
|
if (prop.getKind() !== SyntaxKind.PropertyAssignment) continue;
|
|
const propNameNode = prop.getNameNode?.();
|
|
const propName = propNameNode?.getText();
|
|
const init = prop.getInitializer?.();
|
|
if (propName === "name" && init) {
|
|
const resolved = resolveStringValue(init, project);
|
|
if (resolved !== null) candidateName = resolved;
|
|
}
|
|
if (propName === "validate" || propName === "handler") {
|
|
looksLikeAction = true;
|
|
}
|
|
}
|
|
if (looksLikeAction && candidateName) {
|
|
actionName = candidateName;
|
|
}
|
|
});
|
|
}
|
|
if (!actionName) {
|
|
fail(
|
|
`Could not locate action name in ${filePath}; pass --action-name=NAME explicitly`,
|
|
);
|
|
}
|
|
|
|
const pairs = [];
|
|
for (let i = 0; i < pairArrayNodes.length; i++) {
|
|
const element = pairArrayNodes[i];
|
|
const pair = parseExamplePair(element, i, filePath);
|
|
if (pair) {
|
|
pairs.push({ ...pair, index: i });
|
|
}
|
|
}
|
|
|
|
return { actionName, pairs };
|
|
}
|
|
|
|
/**
|
|
* Find the `examples:` property assignment that belongs to an Action object
|
|
* literal (not a parameter schema). The disambiguation rule mirrors the
|
|
* legacy strategy 2 logic: when the initializer is an inline array literal
|
|
* we require the outer array's first element to itself be an array literal
|
|
* (`[user, agent]` pair shape). For non-array initializers (Identifier,
|
|
* SpreadCall, PropertyAccess) we trust the assignment — those shapes
|
|
* trivially can't be a parameter `examples: string[]` schema list because
|
|
* parameter schemas never reference cross-file identifiers in this
|
|
* codebase.
|
|
*/
|
|
function locateExamplesPropertyAssignment(sourceFile) {
|
|
let result = null;
|
|
sourceFile.forEachDescendant((node) => {
|
|
if (result) return;
|
|
if (node.getKind() !== SyntaxKind.PropertyAssignment) return;
|
|
const prop = node;
|
|
const nameNode = prop.getNameNode?.();
|
|
if (nameNode?.getText() !== "examples") return;
|
|
const init = prop.getInitializer?.();
|
|
if (!init) return;
|
|
|
|
const initKind = init.getKind();
|
|
if (initKind === SyntaxKind.ArrayLiteralExpression) {
|
|
// Disambiguate from a parameter schema's `examples: ["a", "b"]` list.
|
|
const elements = init.getElements();
|
|
const looksLikePairArray =
|
|
elements.length === 0 ||
|
|
elements.some(
|
|
(el) =>
|
|
el.getKind() === SyntaxKind.ArrayLiteralExpression ||
|
|
el.getKind() === SyntaxKind.SpreadElement,
|
|
);
|
|
if (!looksLikePairArray) return;
|
|
} else if (initKind === SyntaxKind.AsExpression) {
|
|
const inner = init.getExpression?.();
|
|
const innerKind = inner?.getKind?.();
|
|
if (
|
|
innerKind !== SyntaxKind.ArrayLiteralExpression &&
|
|
innerKind !== SyntaxKind.Identifier &&
|
|
innerKind !== SyntaxKind.PropertyAccessExpression
|
|
) {
|
|
return;
|
|
}
|
|
} else if (
|
|
initKind !== SyntaxKind.Identifier &&
|
|
initKind !== SyntaxKind.PropertyAccessExpression
|
|
) {
|
|
return;
|
|
}
|
|
|
|
result = {
|
|
initializer: init,
|
|
actionLiteral: prop.getParentIfKind?.(SyntaxKind.ObjectLiteralExpression),
|
|
};
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Recursive resolver: takes any node that should evaluate to
|
|
* `ActionExample[][]` and returns the flat list of pair-array
|
|
* `ArrayLiteralExpression` nodes that compose it.
|
|
*
|
|
* The `seen` set tracks identifier full-text-with-source-file pairs to
|
|
* prevent infinite recursion on cyclic re-exports. `project` is used to
|
|
* lazily add cross-file source files for `getDefinitionNodes()` to follow
|
|
* imports.
|
|
*/
|
|
function resolveToPairArrayNodes(node, project, originSource, seen) {
|
|
if (!node) {
|
|
fail(`resolveToPairArrayNodes: null node`);
|
|
}
|
|
const kind = node.getKind();
|
|
const debugLoc = formatNodeLocation(node);
|
|
|
|
switch (kind) {
|
|
case SyntaxKind.ArrayLiteralExpression: {
|
|
const out = [];
|
|
for (const element of node.getElements()) {
|
|
if (element.getKind() === SyntaxKind.ArrayLiteralExpression) {
|
|
out.push(element);
|
|
continue;
|
|
}
|
|
if (element.getKind() === SyntaxKind.SpreadElement) {
|
|
const spreadInner = element.getExpression?.();
|
|
if (!spreadInner) {
|
|
fail(`Spread element with no inner expression at ${debugLoc}`);
|
|
}
|
|
for (const resolved of resolveToPairArrayNodes(
|
|
spreadInner,
|
|
project,
|
|
originSource,
|
|
seen,
|
|
)) {
|
|
out.push(resolved);
|
|
}
|
|
continue;
|
|
}
|
|
// Skip computed-call placeholders the harness can't safely
|
|
// translate (e.g. `...getDefaultPromptExamplePair("...", "es")`).
|
|
// These were already silently dropped by the legacy extractor.
|
|
// We log so unexpected shapes are visible.
|
|
console.warn(
|
|
`[translate-action-examples] skipping non-literal example element (kind=${kind}) at ${formatNodeLocation(element)}`,
|
|
);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
case SyntaxKind.AsExpression: {
|
|
const inner = node.getExpression?.();
|
|
if (!inner) {
|
|
fail(`AsExpression with no inner expression at ${debugLoc}`);
|
|
}
|
|
return resolveToPairArrayNodes(inner, project, originSource, seen);
|
|
}
|
|
|
|
case SyntaxKind.ParenthesizedExpression: {
|
|
const inner = node.getExpression?.();
|
|
if (!inner) {
|
|
fail(`ParenthesizedExpression with no inner at ${debugLoc}`);
|
|
}
|
|
return resolveToPairArrayNodes(inner, project, originSource, seen);
|
|
}
|
|
|
|
case SyntaxKind.BinaryExpression: {
|
|
// Handle `<expr> ?? []` and `<expr> ?? <expr>`.
|
|
const opToken = node.getOperatorToken?.();
|
|
const opKind = opToken?.getKind?.();
|
|
if (opKind !== SyntaxKind.QuestionQuestionToken) {
|
|
fail(
|
|
`Unsupported BinaryExpression (operator kind=${opKind}) at ${debugLoc}`,
|
|
);
|
|
}
|
|
const left = node.getLeft?.();
|
|
const right = node.getRight?.();
|
|
const leftResolved = resolveToPairArrayNodes(
|
|
left,
|
|
project,
|
|
originSource,
|
|
seen,
|
|
);
|
|
// If the left side already resolved to pair arrays the right side is
|
|
// unreachable when the left is non-nullish; we still merge in case the
|
|
// left has only spread-fallbacks.
|
|
if (leftResolved.length > 0) return leftResolved;
|
|
return resolveToPairArrayNodes(right, project, originSource, seen);
|
|
}
|
|
|
|
case SyntaxKind.Identifier: {
|
|
const name = node.getText?.() ?? "";
|
|
const sourceFilePath = node.getSourceFile?.()?.getFilePath?.() ?? "";
|
|
const seenKey = `${sourceFilePath}::${name}`;
|
|
if (seen.has(seenKey)) {
|
|
fail(
|
|
`Identifier resolution cycle: ${name} at ${debugLoc} already visited`,
|
|
);
|
|
}
|
|
seen.add(seenKey);
|
|
const declInit = resolveIdentifierToInitializer(node, project);
|
|
if (!declInit) {
|
|
fail(
|
|
`Could not resolve identifier "${name}" to a VariableDeclaration initializer at ${debugLoc}`,
|
|
);
|
|
}
|
|
return resolveToPairArrayNodes(declInit, project, originSource, seen);
|
|
}
|
|
|
|
case SyntaxKind.PropertyAccessExpression: {
|
|
// e.g. `someAction.examples` or `someAction?.examples`. Resolve the
|
|
// base expression to its declaration, then look up the named property
|
|
// on that declaration's initializer.
|
|
const propName = node.getName?.();
|
|
const expression = node.getExpression?.();
|
|
if (!propName || !expression) {
|
|
fail(`PropertyAccessExpression with no name/expression at ${debugLoc}`);
|
|
}
|
|
const baseInit = resolveExpressionToInitializer(expression, project);
|
|
if (!baseInit) {
|
|
fail(
|
|
`Could not resolve base of "${node.getText()}" to an initializer at ${debugLoc}`,
|
|
);
|
|
}
|
|
// Walk into ObjectLiteralExpression to find the named property.
|
|
const propInit = findObjectLiteralProperty(baseInit, propName);
|
|
if (!propInit) {
|
|
fail(
|
|
`Could not locate "${propName}" property on resolved base of "${node.getText()}" at ${debugLoc}`,
|
|
);
|
|
}
|
|
return resolveToPairArrayNodes(propInit, project, originSource, seen);
|
|
}
|
|
|
|
default:
|
|
fail(
|
|
`Unsupported initializer kind=${kind} (${node.getKindName?.() ?? "?"}) at ${debugLoc}`,
|
|
);
|
|
return []; // unreachable
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find the `<propName>` PropertyAssignment initializer on an
|
|
* ObjectLiteralExpression. Strips trivial wrappers (AsExpression,
|
|
* Parenthesized) so e.g. `{ examples: [...] as ActionExample[][] }` works.
|
|
*/
|
|
function findObjectLiteralProperty(node, propName) {
|
|
let current = node;
|
|
while (current) {
|
|
const kind = current.getKind?.();
|
|
if (kind === SyntaxKind.AsExpression) {
|
|
current = current.getExpression?.();
|
|
continue;
|
|
}
|
|
if (kind === SyntaxKind.ParenthesizedExpression) {
|
|
current = current.getExpression?.();
|
|
continue;
|
|
}
|
|
if (kind !== SyntaxKind.ObjectLiteralExpression) return null;
|
|
for (const prop of current.getProperties()) {
|
|
if (prop.getKind() !== SyntaxKind.PropertyAssignment) continue;
|
|
const nameNode = prop.getNameNode?.();
|
|
if (nameNode?.getText() !== propName) continue;
|
|
return prop.getInitializer?.() ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Resolve an Identifier to its VariableDeclaration's initializer, following
|
|
* imports across files. Returns null if the identifier resolves to
|
|
* something other than a `const X = <expr>` declaration (e.g. a function
|
|
* parameter, a class member).
|
|
*/
|
|
function resolveIdentifierToInitializer(identifier, project) {
|
|
const defs = identifier.getDefinitionNodes?.() ?? [];
|
|
for (const def of defs) {
|
|
const target = unwrapToVariableInitializer(def, project);
|
|
if (target) return target;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Resolve an arbitrary expression (Identifier, PropertyAccess, or any of
|
|
* those wrapped in `as`/parens) to a VariableDeclaration initializer.
|
|
*/
|
|
function resolveExpressionToInitializer(expression, project) {
|
|
const kind = expression.getKind?.();
|
|
if (
|
|
kind === SyntaxKind.AsExpression ||
|
|
kind === SyntaxKind.ParenthesizedExpression
|
|
) {
|
|
const inner = expression.getExpression?.();
|
|
if (!inner) return null;
|
|
return resolveExpressionToInitializer(inner, project);
|
|
}
|
|
if (kind === SyntaxKind.Identifier) {
|
|
return resolveIdentifierToInitializer(expression, project);
|
|
}
|
|
if (kind === SyntaxKind.PropertyAccessExpression) {
|
|
// `a.b.c` → resolve `a.b` first, then look up `.c`.
|
|
const base = expression.getExpression?.();
|
|
const propName = expression.getName?.();
|
|
if (!base || !propName) return null;
|
|
const baseInit = resolveExpressionToInitializer(base, project);
|
|
if (!baseInit) return null;
|
|
return findObjectLiteralProperty(baseInit, propName);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Given a definition node from `getDefinitionNodes()` (typically a
|
|
* VariableDeclaration, ImportSpecifier, or ExportSpecifier), unwrap it to
|
|
* the underlying VariableDeclaration's initializer expression. Lazily adds
|
|
* cross-file source files to the project so symbol resolution can follow
|
|
* imports.
|
|
*/
|
|
function unwrapToVariableInitializer(node, _project) {
|
|
let current = node;
|
|
for (let hops = 0; hops < 5 && current; hops++) {
|
|
const kind = current.getKind?.();
|
|
if (kind === SyntaxKind.VariableDeclaration) {
|
|
const init = current.getInitializer?.();
|
|
return init ?? null;
|
|
}
|
|
if (
|
|
kind === SyntaxKind.ImportSpecifier ||
|
|
kind === SyntaxKind.ImportClause ||
|
|
kind === SyntaxKind.NamespaceImport
|
|
) {
|
|
// Resolve the import's module specifier and follow to the named
|
|
// export's declaration.
|
|
const importDecl = current.getFirstAncestorByKind?.(
|
|
SyntaxKind.ImportDeclaration,
|
|
);
|
|
if (!importDecl) return null;
|
|
const moduleSourceFile = importDecl.getModuleSpecifierSourceFile?.();
|
|
if (!moduleSourceFile) return null;
|
|
const importedName =
|
|
kind === SyntaxKind.ImportSpecifier
|
|
? (current.getNameNode?.()?.getText?.() ?? null)
|
|
: null;
|
|
if (!importedName) return null;
|
|
// The exported VariableDeclaration in the target source file.
|
|
const targetDecl = moduleSourceFile
|
|
.getVariableDeclarations?.()
|
|
?.find((d) => d.getName?.() === importedName);
|
|
if (targetDecl) {
|
|
return targetDecl.getInitializer?.() ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
if (kind === SyntaxKind.ExportSpecifier) {
|
|
// Re-export. Walk to the underlying local declaration.
|
|
const localTarget = current.getLocalTargetSymbol?.();
|
|
const decls = localTarget?.getDeclarations?.() ?? [];
|
|
if (decls.length === 0) return null;
|
|
current = decls[0];
|
|
continue;
|
|
}
|
|
if (kind === SyntaxKind.Identifier) {
|
|
// Some definition results are bare identifiers — climb to parent
|
|
// VariableDeclaration.
|
|
const parent = current.getParent?.();
|
|
if (parent?.getKind?.() === SyntaxKind.VariableDeclaration) {
|
|
return parent.getInitializer?.() ?? null;
|
|
}
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Resolve an expression to its underlying string literal value. Handles
|
|
* `"FOO"`, `Identifier`-referencing-a-string-const, `as`-wrappers, and
|
|
* parenthesized variants. Returns null if the expression doesn't reduce
|
|
* to a literal string.
|
|
*/
|
|
function resolveStringValue(node, project) {
|
|
if (!node) return null;
|
|
const kind = node.getKind?.();
|
|
if (kind === SyntaxKind.StringLiteral) {
|
|
return node.getLiteralValue?.() ?? null;
|
|
}
|
|
if (kind === SyntaxKind.NoSubstitutionTemplateLiteral) {
|
|
return node.getLiteralValue?.() ?? null;
|
|
}
|
|
if (
|
|
kind === SyntaxKind.AsExpression ||
|
|
kind === SyntaxKind.ParenthesizedExpression
|
|
) {
|
|
return resolveStringValue(node.getExpression?.(), project);
|
|
}
|
|
if (kind === SyntaxKind.Identifier) {
|
|
const init = resolveIdentifierToInitializer(node, project);
|
|
if (!init) return null;
|
|
return resolveStringValue(init, project);
|
|
}
|
|
if (kind === SyntaxKind.PropertyAccessExpression) {
|
|
const init = resolveExpressionToInitializer(node, project);
|
|
if (!init) return null;
|
|
return resolveStringValue(init, project);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Best-effort source location for diagnostics. Format:
|
|
* `<relative-path>:<line>:<col>`.
|
|
*/
|
|
function formatNodeLocation(node) {
|
|
try {
|
|
const sf = node.getSourceFile?.();
|
|
const filePath = sf?.getFilePath?.() ?? "<unknown>";
|
|
const start = node.getStart?.() ?? 0;
|
|
const lineAndCol = sf?.getLineAndColumnAtPos?.(start);
|
|
const line = lineAndCol?.line ?? 0;
|
|
const col = lineAndCol?.column ?? 0;
|
|
const rel = path.relative(process.cwd(), filePath);
|
|
return `${rel}:${line}:${col}`;
|
|
} catch {
|
|
return "<unknown>";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse one `[user, agent]` example pair into a plain JS object. We accept
|
|
* only the two-element shape with `name` + `content.text` (+ optional
|
|
* `actions` / `action`). Anything more exotic (spreads, computed keys) is
|
|
* skipped so the harness can't accidentally translate something unsafe.
|
|
*/
|
|
function parseExamplePair(arrayLiteral, index, filePath) {
|
|
const elements = arrayLiteral.getElements();
|
|
if (elements.length < 2) return null;
|
|
|
|
const turns = [];
|
|
for (let t = 0; t < Math.min(2, elements.length); t++) {
|
|
const turn = elements[t];
|
|
if (turn.getKind() !== SyntaxKind.ObjectLiteralExpression) return null;
|
|
const props = turn.getProperties();
|
|
let name = null;
|
|
let text = null;
|
|
let actions = null;
|
|
let action = null;
|
|
for (const prop of props) {
|
|
if (prop.getKind() !== SyntaxKind.PropertyAssignment) continue;
|
|
const nameNode = prop.getNameNode?.();
|
|
const propName = nameNode?.getText();
|
|
const init = prop.getInitializer?.();
|
|
if (!init) continue;
|
|
if (propName === "name" && init.getKind() === SyntaxKind.StringLiteral) {
|
|
name = init.getLiteralValue();
|
|
} else if (
|
|
propName === "content" &&
|
|
init.getKind() === SyntaxKind.ObjectLiteralExpression
|
|
) {
|
|
for (const cprop of init.getProperties()) {
|
|
if (cprop.getKind() !== SyntaxKind.PropertyAssignment) continue;
|
|
const cname = cprop.getNameNode?.()?.getText();
|
|
const cinit = cprop.getInitializer?.();
|
|
if (!cinit) continue;
|
|
if (cname === "text") {
|
|
if (
|
|
cinit.getKind() === SyntaxKind.StringLiteral ||
|
|
cinit.getKind() === SyntaxKind.NoSubstitutionTemplateLiteral
|
|
) {
|
|
text = cinit.getLiteralValue();
|
|
}
|
|
} else if (
|
|
cname === "actions" &&
|
|
cinit.getKind() === SyntaxKind.ArrayLiteralExpression
|
|
) {
|
|
actions = cinit
|
|
.getElements()
|
|
.filter((e) => e.getKind() === SyntaxKind.StringLiteral)
|
|
.map((e) => e.getLiteralValue());
|
|
} else if (
|
|
cname === "action" &&
|
|
cinit.getKind() === SyntaxKind.StringLiteral
|
|
) {
|
|
action = cinit.getLiteralValue();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!name || text == null) {
|
|
console.warn(
|
|
`[translate-action-examples] skipping pair #${index} in ${path.basename(
|
|
filePath,
|
|
)}: missing name or text`,
|
|
);
|
|
return null;
|
|
}
|
|
turns.push({ name, content: { text, actions, action } });
|
|
}
|
|
|
|
return { user: turns[0], agent: turns[1] };
|
|
}
|
|
|
|
/**
|
|
* Build the strict translation prompt. The model returns one JSON object
|
|
* with `userText` + `agentText`. We deliberately do NOT ask it to translate
|
|
* speaker names, action tokens, or `{{name1}}`/`{{agentName}}` placeholders.
|
|
*/
|
|
function buildTranslationPrompt(pair, locale) {
|
|
const localeName = {
|
|
es: "Spanish (es)",
|
|
fr: "French (fr)",
|
|
ja: "Japanese (ja)",
|
|
}[locale];
|
|
return [
|
|
`You translate an ActionExample dialog pair from English into ${localeName}.`,
|
|
"",
|
|
"Rules:",
|
|
"- Translate ONLY the user message text and the agent reply text.",
|
|
"- DO NOT translate or alter speaker placeholders like {{name1}} or {{agentName}}.",
|
|
"- DO NOT translate action tokens (e.g. LIFE, MESSAGE_HANDOFF, SCHEDULED_TASK).",
|
|
"- Preserve tone, terseness, and confirm/preview semantics if present.",
|
|
"- DO NOT introduce PII (names, phones, emails) that wasn't in the input.",
|
|
"- Numbers, times (8 am / 9 pm), monetary amounts, and quoted titles stay as-is unless idiomatic in the target locale.",
|
|
'- Output ONLY a JSON object: {"userText": "...", "agentText": "..."} — no prose, no fences.',
|
|
"",
|
|
"Source pair:",
|
|
JSON.stringify(
|
|
{ userText: pair.user.content.text, agentText: pair.agent.content.text },
|
|
null,
|
|
2,
|
|
),
|
|
].join("\n");
|
|
}
|
|
|
|
async function callCerebras(prompt) {
|
|
const apiKey = process.env.CEREBRAS_API_KEY;
|
|
if (!apiKey) {
|
|
fail("CEREBRAS_API_KEY is not set");
|
|
}
|
|
const baseUrl = process.env.CEREBRAS_BASE_URL ?? DEFAULT_BASE_URL;
|
|
const model = process.env.CEREBRAS_MODEL ?? DEFAULT_MODEL;
|
|
const body = {
|
|
model,
|
|
messages: [
|
|
{
|
|
role: "system",
|
|
content:
|
|
"You are a careful translator. Output JSON only. Never add commentary.",
|
|
},
|
|
{ role: "user", content: prompt },
|
|
],
|
|
temperature: 0,
|
|
max_tokens: 1024,
|
|
};
|
|
if (model.startsWith("gpt-oss")) {
|
|
body.reasoning_effort = "low";
|
|
}
|
|
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${apiKey}`,
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!response.ok) {
|
|
const errBody = await response.text();
|
|
throw new Error(
|
|
`cerebras error ${response.status}: ${errBody.slice(0, 300)}`,
|
|
);
|
|
}
|
|
const data = await response.json();
|
|
const text = data?.choices?.[0]?.message?.content ?? "";
|
|
if (!text) {
|
|
throw new Error("cerebras returned empty content");
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function parseTranslationJson(raw) {
|
|
// Tolerate a leading ```json fence purely on the off-chance the model
|
|
// ignores instructions; throw on anything else (no silent fallback).
|
|
let body = raw.trim();
|
|
if (body.startsWith("```")) {
|
|
const firstNewline = body.indexOf("\n");
|
|
body = body.slice(firstNewline + 1);
|
|
const lastFence = body.lastIndexOf("```");
|
|
if (lastFence >= 0) body = body.slice(0, lastFence);
|
|
body = body.trim();
|
|
}
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(body);
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Could not parse translation JSON: ${err instanceof Error ? err.message : String(err)}\nRaw: ${raw.slice(0, 400)}`,
|
|
);
|
|
}
|
|
if (
|
|
typeof parsed?.userText !== "string" ||
|
|
typeof parsed?.agentText !== "string" ||
|
|
parsed.userText.length === 0 ||
|
|
parsed.agentText.length === 0
|
|
) {
|
|
throw new Error(
|
|
`Translation JSON missing userText/agentText:\n${JSON.stringify(parsed)}`,
|
|
);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
/**
|
|
* Render the final TypeScript file: a self-registering pack that calls
|
|
* `registry.register(...)` for each translated pair, keyed
|
|
* `<actionName>.example.<index>:<locale>` so the registry composite key is
|
|
* unique. Action authors then call
|
|
* `getDefaultPromptExamplePair("<actionName>.example.<index>", "es")` to
|
|
* pull the pair into their `examples` array (mirrors the W2-E pattern).
|
|
*/
|
|
function renderRegistryPack({ actionName, locale, translations }) {
|
|
const lines = [];
|
|
lines.push(
|
|
"// AUTOGENERATED by plugins/plugin-personal-assistant/scripts/translate-action-examples.mjs",
|
|
);
|
|
lines.push("// Do not edit by hand. Re-run the harness to regenerate.");
|
|
lines.push(`// action: ${actionName}`);
|
|
lines.push(`// locale: ${locale}`);
|
|
lines.push("");
|
|
lines.push(
|
|
'import type { PromptExampleEntry } from "../prompt-registry.js";',
|
|
);
|
|
lines.push("");
|
|
lines.push(
|
|
`export const ${packVarName(actionName, locale)}: ReadonlyArray<PromptExampleEntry> = [`,
|
|
);
|
|
for (const t of translations) {
|
|
const key = `${actionName}.example.${t.index}`;
|
|
const userActions = t.userActions ? actionsLiteral(t.userActions) : "";
|
|
const userAction = t.userAction
|
|
? `, action: ${jsonString(t.userAction)}`
|
|
: "";
|
|
const agentActions = t.agentActions ? actionsLiteral(t.agentActions) : "";
|
|
const agentAction = t.agentAction
|
|
? `, action: ${jsonString(t.agentAction)}`
|
|
: "";
|
|
lines.push(" {");
|
|
lines.push(` exampleKey: ${jsonString(key)},`);
|
|
lines.push(` locale: ${jsonString(locale)},`);
|
|
lines.push(` user: {`);
|
|
lines.push(` name: ${jsonString(t.userName)},`);
|
|
lines.push(
|
|
` content: { text: ${jsonString(t.userText)}${userActions}${userAction} },`,
|
|
);
|
|
lines.push(` },`);
|
|
lines.push(` agent: {`);
|
|
lines.push(` name: ${jsonString(t.agentName)},`);
|
|
lines.push(
|
|
` content: { text: ${jsonString(t.agentText)}${agentActions}${agentAction} },`,
|
|
);
|
|
lines.push(` },`);
|
|
lines.push(" },");
|
|
}
|
|
lines.push("];");
|
|
lines.push("");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function packVarName(actionName, locale) {
|
|
const safe = actionName.toLowerCase().replace(/[^a-z0-9]+/g, "_");
|
|
return `${safe}_${locale}_examples`;
|
|
}
|
|
|
|
function jsonString(value) {
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function actionsLiteral(arr) {
|
|
return `, actions: [${arr.map(jsonString).join(", ")}]`;
|
|
}
|
|
|
|
async function main() {
|
|
const opts = parseArgs(process.argv);
|
|
const absInput = path.isAbsolute(opts.file)
|
|
? opts.file
|
|
: path.resolve(process.cwd(), opts.file);
|
|
if (!fs.existsSync(absInput)) {
|
|
fail(`Action file not found: ${absInput}`);
|
|
}
|
|
const { actionName, pairs } = extractFromActionFile(
|
|
absInput,
|
|
opts.actionName,
|
|
);
|
|
const cap = Math.min(opts.maxExamples, pairs.length);
|
|
const subset = pairs.slice(0, cap);
|
|
|
|
console.info(
|
|
`[translate-action-examples] action="${actionName}" file="${path.relative(
|
|
process.cwd(),
|
|
absInput,
|
|
)}" pairs=${pairs.length} translating=${subset.length} locales=${opts.targetLocales.join(",")}`,
|
|
);
|
|
|
|
const summary = { calls: 0, written: [] };
|
|
|
|
for (const locale of opts.targetLocales) {
|
|
const translations = [];
|
|
for (const pair of subset) {
|
|
const prompt = buildTranslationPrompt(pair, locale);
|
|
let raw;
|
|
if (opts.dryRun) {
|
|
raw = JSON.stringify({
|
|
userText: `[dry-run:${locale}] ${pair.user.content.text}`,
|
|
agentText: `[dry-run:${locale}] ${pair.agent.content.text}`,
|
|
});
|
|
} else {
|
|
raw = await callCerebras(prompt);
|
|
summary.calls += 1;
|
|
}
|
|
const { userText, agentText } = parseTranslationJson(raw);
|
|
translations.push({
|
|
index: pair.index,
|
|
userName: pair.user.name,
|
|
userText,
|
|
userActions: pair.user.content.actions ?? null,
|
|
userAction: pair.user.content.action ?? null,
|
|
agentName: pair.agent.name,
|
|
agentText,
|
|
agentActions: pair.agent.content.actions ?? null,
|
|
agentAction: pair.agent.content.action ?? null,
|
|
});
|
|
console.info(
|
|
`[translate-action-examples] pair[${pair.index}] -> ${locale}: ${userText.slice(0, 60)}...`,
|
|
);
|
|
}
|
|
|
|
const rendered = renderRegistryPack({ actionName, locale, translations });
|
|
|
|
if (opts.outputPath) {
|
|
const outPath = path.isAbsolute(opts.outputPath)
|
|
? opts.outputPath
|
|
: path.resolve(process.cwd(), opts.outputPath);
|
|
// When multiple locales are passed, splice locale into filename.
|
|
const finalPath =
|
|
opts.targetLocales.length === 1
|
|
? outPath
|
|
: outPath.replace(/(\.[a-z]+)$/, `.${locale}$1`);
|
|
fs.mkdirSync(path.dirname(finalPath), { recursive: true });
|
|
fs.writeFileSync(finalPath, rendered, "utf8");
|
|
summary.written.push(finalPath);
|
|
console.info(
|
|
`[translate-action-examples] wrote ${path.relative(process.cwd(), finalPath)}`,
|
|
);
|
|
} else {
|
|
process.stdout.write(`\n// ===== ${locale} =====\n${rendered}\n`);
|
|
}
|
|
}
|
|
|
|
console.info(
|
|
`[translate-action-examples] done. cerebras_calls=${summary.calls} files_written=${summary.written.length}`,
|
|
);
|
|
}
|
|
|
|
// Run as CLI when executed directly, but stay importable for tests.
|
|
const __isDirectInvocation =
|
|
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
|
if (__isDirectInvocation) {
|
|
await main();
|
|
}
|
|
|
|
export {
|
|
extractFromActionFile,
|
|
parseExamplePair,
|
|
resolveStringValue,
|
|
resolveToPairArrayNodes,
|
|
};
|