@avocadostudio-ai/orchestrator-core 0.22.0 → 0.23.0
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/dist/agent/agent-loop-openai.js +3 -1
- package/dist/agent/agent-provider.js +2 -2
- package/dist/agents/backtest.d.ts +35 -0
- package/dist/agents/backtest.js +92 -0
- package/dist/agents/builtins.d.ts +21 -0
- package/dist/agents/builtins.js +102 -0
- package/dist/agents/draft-spec.d.ts +44 -0
- package/dist/agents/draft-spec.js +195 -0
- package/dist/agents/edit-safety-resolve.d.ts +70 -0
- package/dist/agents/edit-safety-resolve.js +195 -0
- package/dist/agents/edit-safety-runner.d.ts +68 -0
- package/dist/agents/edit-safety-runner.js +156 -0
- package/dist/agents/edit-safety.d.ts +82 -0
- package/dist/agents/edit-safety.js +425 -0
- package/dist/agents/fix-writer.d.ts +38 -0
- package/dist/agents/fix-writer.js +197 -0
- package/dist/agents/inbox.d.ts +141 -0
- package/dist/agents/inbox.js +360 -0
- package/dist/agents/learning.d.ts +32 -0
- package/dist/agents/learning.js +71 -0
- package/dist/agents/mention.d.ts +76 -0
- package/dist/agents/mention.js +161 -0
- package/dist/agents/run-spec.d.ts +27 -0
- package/dist/agents/run-spec.js +82 -0
- package/dist/agents/schedule.d.ts +22 -0
- package/dist/agents/schedule.js +53 -0
- package/dist/agents/settings.d.ts +30 -0
- package/dist/agents/settings.js +64 -0
- package/dist/agents/spec.d.ts +84 -0
- package/dist/agents/spec.js +70 -0
- package/dist/agents/stats.d.ts +15 -0
- package/dist/agents/stats.js +48 -0
- package/dist/agents/templates.d.ts +34 -0
- package/dist/agents/templates.js +268 -0
- package/dist/agents/tick.d.ts +59 -0
- package/dist/agents/tick.js +161 -0
- package/dist/chat/anthropic-model-caps.d.ts +19 -0
- package/dist/chat/anthropic-model-caps.js +56 -0
- package/dist/chat/anthropic-planner.d.ts +1 -0
- package/dist/chat/anthropic-planner.js +72 -15
- package/dist/chat/chat-pipeline.js +54 -1
- package/dist/chat/model-defaults.js +6 -3
- package/dist/checks/rules-draft.d.ts +6 -1
- package/dist/checks/rules-draft.js +10 -7
- package/dist/checks/rules-i18n.d.ts +25 -0
- package/dist/checks/rules-i18n.js +193 -0
- package/dist/checks/run-checks.d.ts +16 -1
- package/dist/checks/run-checks.js +80 -63
- package/dist/checks/session-runner.js +7 -0
- package/dist/checks/types.d.ts +6 -0
- package/dist/durable/in-memory-durable-store.d.ts +34 -1
- package/dist/durable/in-memory-durable-store.js +122 -1
- package/dist/durable/index.d.ts +1 -1
- package/dist/durable/sqlite-durable-store.d.ts +19 -1
- package/dist/durable/sqlite-durable-store.js +220 -1
- package/dist/durable/types.d.ts +146 -0
- package/dist/handler/create-orchestrator.js +191 -1
- package/dist/http/history-actions.d.ts +22 -0
- package/dist/http/history-actions.js +33 -5
- package/dist/http/inbox-actions.d.ts +156 -0
- package/dist/http/inbox-actions.js +498 -0
- package/dist/http/ops-actions.d.ts +17 -1
- package/dist/http/ops-actions.js +36 -0
- package/dist/http/variation-preview-actions.d.ts +30 -0
- package/dist/http/variation-preview-actions.js +75 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +9 -0
- package/dist/nlp/deterministic-planner.d.ts +1 -1
- package/dist/nlp/intent-detection.d.ts +15 -0
- package/dist/nlp/intent-detection.js +5 -0
- package/dist/state/session-state.d.ts +11 -3
- package/dist/state/session-state.js +8 -0
- package/dist/state/sqlite-store.d.ts +12 -0
- package/dist/telemetry/usage.js +4 -0
- package/package.json +3 -3
|
@@ -9,6 +9,7 @@ import { buildPlannerSchemaContext, extractUpdatePropsFieldDraftsFromPlanBuffer,
|
|
|
9
9
|
import { buildEditPlanJsonSchema, intentJsonSchema } from "./plan-json-schema.js";
|
|
10
10
|
import { extractUsage, ZERO_USAGE } from "../telemetry/usage.js";
|
|
11
11
|
import { anthropicSystemPromptWithCache, anthropicSegmentedSystemPromptWithCache, anthropicToolWithCache, ANTHROPIC_FINE_GRAINED_STREAM_HEADERS } from "./anthropic-cache.js";
|
|
12
|
+
import { minimalThinkingParams, supportsForcedToolChoice } from "./anthropic-model-caps.js";
|
|
12
13
|
import { executeToolCall } from "../tools/runtime.js";
|
|
13
14
|
import { DEFERRABLE_IMAGE_TOOLS } from "./chat-pipeline-shared.js";
|
|
14
15
|
import { buildFullPageTranslationChecklist } from "./chat-pipeline-translation.js";
|
|
@@ -111,7 +112,7 @@ function salvageTruncatedPlan(args) {
|
|
|
111
112
|
// ANTHROPIC_API_KEY isn't set yet.
|
|
112
113
|
// ---------------------------------------------------------------------------
|
|
113
114
|
let _anthropicSingleton = null;
|
|
114
|
-
function getAnthropicClient() {
|
|
115
|
+
export function getAnthropicClient() {
|
|
115
116
|
if (!_anthropicSingleton) {
|
|
116
117
|
_anthropicSingleton = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
117
118
|
}
|
|
@@ -252,6 +253,19 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
252
253
|
attachmentCount: args.contextPack.attachments?.length,
|
|
253
254
|
attachmentImages: args.contextPack.attachments?.filter((attachment) => attachment.kind === "image").map(({ url, name }) => ({ url, name })),
|
|
254
255
|
});
|
|
256
|
+
// Sonnet 5.5 / Opus 5.5 reject a forced tool_choice, so on them the planner
|
|
257
|
+
// can only ask for the submit_edit_plan call rather than require it. The
|
|
258
|
+
// request goes in the uncached tail; it is constant per model, and a model
|
|
259
|
+
// switch misses the cache anyway.
|
|
260
|
+
const forcedToolChoiceOk = supportsForcedToolChoice(args.model);
|
|
261
|
+
if (!forcedToolChoiceOk) {
|
|
262
|
+
systemSegments.dynamic = [
|
|
263
|
+
systemSegments.dynamic,
|
|
264
|
+
"RESPONSE CHANNEL: finish by calling the submit_edit_plan tool exactly once with the complete EditPlan. " +
|
|
265
|
+
"This includes questions and clarifications (intent needs_clarification) and answers that change nothing. " +
|
|
266
|
+
"Never reply in plain text instead of calling submit_edit_plan."
|
|
267
|
+
].filter((part) => part.trim().length > 0).join("\n\n");
|
|
268
|
+
}
|
|
255
269
|
// Stable prefix carries cache_control; dynamic tail is sent as a second
|
|
256
270
|
// text block without it, so per-request flags don't bust the cached prefix.
|
|
257
271
|
const cachedSystem = anthropicSegmentedSystemPromptWithCache(systemSegments);
|
|
@@ -413,9 +427,13 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
413
427
|
? { type: "adaptive", display: "summarized" }
|
|
414
428
|
: undefined;
|
|
415
429
|
const effortParam = thinkingParam ? args.thinking.effort : undefined;
|
|
430
|
+
// "Thinking OFF" is spelled per model: nothing on older models, `between_tools`
|
|
431
|
+
// on Sonnet 5.5, low effort on Opus 5.5 (which always thinks). See
|
|
432
|
+
// anthropic-model-caps.ts.
|
|
433
|
+
const minimalThinking = minimalThinkingParams(args.model);
|
|
416
434
|
// Adaptive thinking counts thinking tokens against max_tokens; give headroom so
|
|
417
435
|
// the actual response isn't truncated. Effort (not max_tokens) governs spend.
|
|
418
|
-
const baseMaxTokens = thinkingParam ? 16000 : 8192;
|
|
436
|
+
const baseMaxTokens = thinkingParam || minimalThinking.output_config ? 16000 : 8192;
|
|
419
437
|
// Whole-page translations and rewrites re-emit every translatable string on the
|
|
420
438
|
// page inside an op envelope, so their output length scales with the page — a
|
|
421
439
|
// flat budget truncates them.
|
|
@@ -445,15 +463,34 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
445
463
|
// decoding fallback (thinking OFF) sets output_config.format instead.
|
|
446
464
|
const thinkingRequestParams = thinkingParam
|
|
447
465
|
? { thinking: thinkingParam, output_config: { effort: effortParam } }
|
|
448
|
-
:
|
|
466
|
+
: minimalThinking;
|
|
467
|
+
// Where thinking is off, the submit_edit_plan call is forced — on models that
|
|
468
|
+
// allow it. Sonnet 5.5 / Opus 5.5 400 on a forced tool_choice, so they get
|
|
469
|
+
// `auto` plus the RESPONSE CHANNEL instruction in the system prompt.
|
|
470
|
+
const forcedSubmitChoice = forcedToolChoiceOk
|
|
471
|
+
? { type: "tool", name: "submit_edit_plan" }
|
|
472
|
+
: { type: "auto" };
|
|
449
473
|
// Plan-first review mode forces the submit_edit_plan tool so the model cannot
|
|
450
474
|
// emit free-form prose (which the text-only fallback wraps as
|
|
451
475
|
// needs_clarification). Combined with the narrowed intent enum, this
|
|
452
476
|
// guarantees a reviewable edit_plan. Safe here because planFirstMode also
|
|
453
477
|
// disables thinking above, and forced tool_choice is incompatible with thinking.
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
478
|
+
// Where forcing is unavailable, the tool loop below asks once more instead.
|
|
479
|
+
const submitToolChoice = planFirstMode ? forcedSubmitChoice : { type: "auto" };
|
|
480
|
+
// On a model that cannot be forced, a prose-only reply is the model choosing
|
|
481
|
+
// to talk rather than plan — the same thing the auto/thinking path has always
|
|
482
|
+
// wrapped as needs_clarification, so it gets the same treatment.
|
|
483
|
+
const textOnlyPlan = (text) => {
|
|
484
|
+
const trimmed = text.trim();
|
|
485
|
+
if (forcedToolChoiceOk || trimmed.length <= MIN_MEANINGFUL_RESPONSE_LENGTH)
|
|
486
|
+
return null;
|
|
487
|
+
emitThinkingEnd();
|
|
488
|
+
return {
|
|
489
|
+
plan: { intent: "needs_clarification", summary_for_user: trimmed, change_log: [], ops: [] },
|
|
490
|
+
usage,
|
|
491
|
+
schemaContext: schemaContext.meta
|
|
492
|
+
};
|
|
493
|
+
};
|
|
457
494
|
// Track thinking lifecycle for callbacks (Path 1 tool-loop and Path 2 stream)
|
|
458
495
|
let thinkingStartedAt = null;
|
|
459
496
|
const emitThinkingStart = () => {
|
|
@@ -709,6 +746,15 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
709
746
|
if (parsed)
|
|
710
747
|
break;
|
|
711
748
|
}
|
|
749
|
+
// Plan-first review needs a plan, and this model could not be forced to
|
|
750
|
+
// submit one. Ask once, appending rather than editing the history so the
|
|
751
|
+
// reply's thinking blocks stay valid for the next request.
|
|
752
|
+
if (planFirstMode && !forcedToolChoiceOk && turn < maxToolTurns - 1 && response.content.length > 0) {
|
|
753
|
+
args.log?.warn({ event: "anthropic_planner_submit_nudge", model: args.model, turn }, "Anthropic planner: no submit_edit_plan call in plan-first mode — asking again");
|
|
754
|
+
loopMessages.push({ role: "assistant", content: response.content });
|
|
755
|
+
loopMessages.push({ role: "user", content: "Call the submit_edit_plan tool now with your complete EditPlan." });
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
712
758
|
// Model responded with text only (no tool call). If we have meaningful
|
|
713
759
|
// text content, treat it as an "info" response rather than discarding it.
|
|
714
760
|
const trimmed = raw.trim();
|
|
@@ -845,9 +891,7 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
845
891
|
max_tokens: plannerStreamMaxTokens,
|
|
846
892
|
system: cachedSystem,
|
|
847
893
|
tools: [anthropicToolWithCache(submitPlanToolDef)],
|
|
848
|
-
tool_choice: thinkingParam
|
|
849
|
-
? { type: "auto" }
|
|
850
|
-
: { type: "tool", name: "submit_edit_plan" },
|
|
894
|
+
tool_choice: thinkingParam ? { type: "auto" } : forcedSubmitChoice,
|
|
851
895
|
messages: [
|
|
852
896
|
...historyMessages,
|
|
853
897
|
{ role: "user", content: userContent }
|
|
@@ -972,6 +1016,11 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
972
1016
|
throw new Error("Model response was truncated (max_tokens reached)");
|
|
973
1017
|
parsed = salvaged;
|
|
974
1018
|
}
|
|
1019
|
+
if (!parsed) {
|
|
1020
|
+
const textOnly = textOnlyPlan(textBuf);
|
|
1021
|
+
if (textOnly)
|
|
1022
|
+
return textOnly;
|
|
1023
|
+
}
|
|
975
1024
|
}
|
|
976
1025
|
else {
|
|
977
1026
|
const response = await client.messages.create({
|
|
@@ -979,9 +1028,7 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
979
1028
|
max_tokens: plannerMaxTokens,
|
|
980
1029
|
system: cachedSystem,
|
|
981
1030
|
tools: [anthropicToolWithCache(submitPlanToolDef)],
|
|
982
|
-
tool_choice: thinkingParam
|
|
983
|
-
? { type: "auto" }
|
|
984
|
-
: { type: "tool", name: "submit_edit_plan" },
|
|
1031
|
+
tool_choice: thinkingParam ? { type: "auto" } : forcedSubmitChoice,
|
|
985
1032
|
messages: [
|
|
986
1033
|
...historyMessages,
|
|
987
1034
|
{ role: "user", content: userContent }
|
|
@@ -1019,6 +1066,11 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
1019
1066
|
catch { /* fall through */ }
|
|
1020
1067
|
}
|
|
1021
1068
|
}
|
|
1069
|
+
if (!parsed) {
|
|
1070
|
+
const textOnly = textOnlyPlan(raw);
|
|
1071
|
+
if (textOnly)
|
|
1072
|
+
return textOnly;
|
|
1073
|
+
}
|
|
1022
1074
|
}
|
|
1023
1075
|
}
|
|
1024
1076
|
}
|
|
@@ -1034,7 +1086,9 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
1034
1086
|
model: args.model,
|
|
1035
1087
|
max_tokens: plannerMaxTokens,
|
|
1036
1088
|
system: cachedSystem,
|
|
1089
|
+
...minimalThinking,
|
|
1037
1090
|
output_config: {
|
|
1091
|
+
...minimalThinking.output_config,
|
|
1038
1092
|
format: { type: "json_schema", schema: buildEditPlanJsonSchema({ planFirstMode }) }
|
|
1039
1093
|
},
|
|
1040
1094
|
messages: [
|
|
@@ -1090,9 +1144,7 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
1090
1144
|
max_tokens: plannerMaxTokens,
|
|
1091
1145
|
system: cachedSystem,
|
|
1092
1146
|
tools: [anthropicToolWithCache(submitPlanToolDef)],
|
|
1093
|
-
tool_choice: thinkingParam
|
|
1094
|
-
? { type: "auto" }
|
|
1095
|
-
: { type: "tool", name: "submit_edit_plan" },
|
|
1147
|
+
tool_choice: thinkingParam ? { type: "auto" } : forcedSubmitChoice,
|
|
1096
1148
|
messages: [
|
|
1097
1149
|
...historyMessages,
|
|
1098
1150
|
{ role: "user", content: userContent }
|
|
@@ -1130,6 +1182,11 @@ export async function generatePlanWithAnthropic(args) {
|
|
|
1130
1182
|
catch { /* fall through */ }
|
|
1131
1183
|
}
|
|
1132
1184
|
}
|
|
1185
|
+
if (!parsed) {
|
|
1186
|
+
const textOnly = textOnlyPlan(raw);
|
|
1187
|
+
if (textOnly)
|
|
1188
|
+
return textOnly;
|
|
1189
|
+
}
|
|
1133
1190
|
}
|
|
1134
1191
|
}
|
|
1135
1192
|
}
|
|
@@ -63,6 +63,8 @@ import { runVariationPipeline, getCachedVariations } from "./variation-pipeline.
|
|
|
63
63
|
import { validateAndStripHallucinatedProps } from "./hallucination-validator.js";
|
|
64
64
|
import { validateChangelogCoverage } from "./changelog-coverage-validator.js";
|
|
65
65
|
import { generateAltTextFromVision, parseAltPathForOp } from "./vision-alt-generator.js";
|
|
66
|
+
import { checkEditSafety, pagesBefore, summarizeReport } from "../agents/edit-safety-runner.js";
|
|
67
|
+
import { recordOutcome, siteFactsForPlanner } from "../agents/learning.js";
|
|
66
68
|
/**
|
|
67
69
|
* Whether the CURRENT message plausibly depends on prior conversation turns — an
|
|
68
70
|
* anaphoric or very terse follow-up ("make it bigger", "undo", "do that again").
|
|
@@ -432,6 +434,16 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
432
434
|
return { code: 409, payload: { error: "pending plan mismatch" } };
|
|
433
435
|
}
|
|
434
436
|
await clearPendingPlan(body.session, "discarded");
|
|
437
|
+
// Stopping a held plan is a person rejecting a proposal (SO-11).
|
|
438
|
+
await recordOutcome({
|
|
439
|
+
scopeKey: body.session,
|
|
440
|
+
traceId: existing.id,
|
|
441
|
+
slug: existing.effectiveSlug,
|
|
442
|
+
request: existing.originalMessage ?? existing.summary,
|
|
443
|
+
proposed: existing.plan.ops,
|
|
444
|
+
outcome: "rejected",
|
|
445
|
+
log: ctx.log
|
|
446
|
+
});
|
|
435
447
|
return {
|
|
436
448
|
code: 200,
|
|
437
449
|
payload: {
|
|
@@ -503,7 +515,9 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
503
515
|
*/
|
|
504
516
|
const siteLocales = resolveSiteLocales(getSiteConfig(body.session), Array.from(getSessionDraft(body.session).keys()));
|
|
505
517
|
const siteLanguagesLine = siteLocales ? siteLanguagesContextLine(siteLocales, body.slug) : undefined;
|
|
518
|
+
const siteFacts = await siteFactsForPlanner(body.session, ctx.log);
|
|
506
519
|
const siteContextBlock = buildSiteContextBlock({
|
|
520
|
+
...(siteFacts.length > 0 ? { siteFacts } : {}),
|
|
507
521
|
sitePurpose: body.sitePurpose,
|
|
508
522
|
siteHosting: body.siteHosting,
|
|
509
523
|
businessContext: body.businessContext,
|
|
@@ -2255,6 +2269,12 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
2255
2269
|
};
|
|
2256
2270
|
}
|
|
2257
2271
|
const previous = getPage(body.session, effectiveSlug);
|
|
2272
|
+
// Every page the plan names, before it is applied — Edit safety checks each
|
|
2273
|
+
// of them, not only the page this turn was about. A streamed turn has
|
|
2274
|
+
// already applied its ops, and hands over the snapshots it took on the way.
|
|
2275
|
+
const editSafetyBefore = optionsOverride?.preApplied && optionsOverride.rollbackSnapshots
|
|
2276
|
+
? new Map(optionsOverride.rollbackSnapshots)
|
|
2277
|
+
: pagesBefore(resolvedPlan.ops, (slug) => getPage(body.session, slug), [effectiveSlug]);
|
|
2258
2278
|
const hasCreatePage = resolvedPlan.ops.some((op) => op.op === "create_page");
|
|
2259
2279
|
if (!previous && !hasCreatePage) {
|
|
2260
2280
|
return {
|
|
@@ -2950,6 +2970,16 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
2950
2970
|
*/
|
|
2951
2971
|
if (optionsOverride?.approvedPlanId) {
|
|
2952
2972
|
await clearPendingPlan(body.session, "approved", optionsOverride.approvedPlanId);
|
|
2973
|
+
await recordOutcome({
|
|
2974
|
+
scopeKey: body.session,
|
|
2975
|
+
traceId: optionsOverride.approvedPlanId,
|
|
2976
|
+
slug: effectiveSlug,
|
|
2977
|
+
request: body.message ?? resolvedPlan.summary_for_user,
|
|
2978
|
+
proposed: resolvedPlan.ops,
|
|
2979
|
+
outcome: "accepted",
|
|
2980
|
+
applied: resolvedPlan.ops,
|
|
2981
|
+
log: ctx.log
|
|
2982
|
+
});
|
|
2953
2983
|
}
|
|
2954
2984
|
else {
|
|
2955
2985
|
await clearPendingPlan(body.session, "discarded");
|
|
@@ -2969,9 +2999,27 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
2969
2999
|
opTypes: resolvedPlan.ops.map((op) => op.op),
|
|
2970
3000
|
opCount: resolvedPlan.ops.length,
|
|
2971
3001
|
source: "chat",
|
|
3002
|
+
actor: { kind: "chat" },
|
|
2972
3003
|
snapshot: versionSnapshot ? structuredClone(versionSnapshot) : null
|
|
2973
3004
|
});
|
|
2974
3005
|
schedulePersistState(ctx.log);
|
|
3006
|
+
// Every page the plan touched, each diffed against itself before the
|
|
3007
|
+
// turn. A created or removed page has no before or no after, and is left
|
|
3008
|
+
// to the destructive-action gate.
|
|
3009
|
+
if (skipApply && undoSnapshot && !editSafetyBefore.has(effectiveSlug))
|
|
3010
|
+
editSafetyBefore.set(effectiveSlug, undoSnapshot);
|
|
3011
|
+
const editSafetyReport = editSafetyBefore.size > 0
|
|
3012
|
+
? await checkEditSafety({
|
|
3013
|
+
scopeKey: body.session,
|
|
3014
|
+
before: editSafetyBefore,
|
|
3015
|
+
afterOf: (slug) => getPage(body.session, slug) ?? null,
|
|
3016
|
+
ops: resolvedPlan.ops,
|
|
3017
|
+
source: "chat",
|
|
3018
|
+
...(body.message ? { request: body.message } : {}),
|
|
3019
|
+
traceId: chatRequestId,
|
|
3020
|
+
log: ctx.log
|
|
3021
|
+
})
|
|
3022
|
+
: null;
|
|
2975
3023
|
const focusBlockId = pickFocusBlockId(resolvedPlan.ops);
|
|
2976
3024
|
const aiInsightChanges = buildAiInsightChanges({ plan: resolvedPlan, message: plannerMessage });
|
|
2977
3025
|
const metaChangeLogEntries = buildMetaChangeLogEntries(resolvedPlan.ops);
|
|
@@ -3039,6 +3087,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
3039
3087
|
focusBlockId,
|
|
3040
3088
|
updatedSlug,
|
|
3041
3089
|
undoSlug: undoTargetSlug,
|
|
3090
|
+
...(editSafetyReport ? { editSafety: summarizeReport(editSafetyReport) } : {}),
|
|
3042
3091
|
plannerSource: source,
|
|
3043
3092
|
modelUsed,
|
|
3044
3093
|
modelKey
|
|
@@ -4604,7 +4653,11 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
4604
4653
|
: `streamed apply covered ${streamApplyState.processedCount} of ${initialPlan.ops.length} planned ops`);
|
|
4605
4654
|
}
|
|
4606
4655
|
const initialOutcome = await respondFromPlan(initialPlan, plannerSource, applyMode, streamedApplyComplete
|
|
4607
|
-
? {
|
|
4656
|
+
? {
|
|
4657
|
+
preApplied: true,
|
|
4658
|
+
undoSnapshot: streamApplyState.rollbackSnapshots.get(effectiveSlug) ?? undefined,
|
|
4659
|
+
rollbackSnapshots: new Map(streamApplyState.rollbackSnapshots)
|
|
4660
|
+
}
|
|
4608
4661
|
: undefined, "full_llm");
|
|
4609
4662
|
if (initialOutcome.done)
|
|
4610
4663
|
return initialOutcome.response;
|
|
@@ -33,9 +33,12 @@ export const defaultModelLookup = () => ({
|
|
|
33
33
|
},
|
|
34
34
|
anthropic: {
|
|
35
35
|
fast: process.env.ANTHROPIC_MODEL_FAST ?? "claude-haiku-4-5-20251001",
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
// Sonnet 5.5 / Opus 5.5 reject a forced tool_choice and a disabled thinking
|
|
37
|
+
// block; the planner adapts per model (see anthropic-model-caps.ts), so an
|
|
38
|
+
// override back to Sonnet 5 or Opus 4.8 still works.
|
|
39
|
+
balanced: process.env.ANTHROPIC_MODEL_BALANCED ?? "claude-sonnet-5-5",
|
|
40
|
+
reasoning: process.env.ANTHROPIC_MODEL_REASONING ?? "claude-sonnet-5-5",
|
|
41
|
+
codex: process.env.ANTHROPIC_MODEL_CODEX ?? "claude-opus-5-5"
|
|
39
42
|
},
|
|
40
43
|
gemini: {
|
|
41
44
|
fast: process.env.GOOGLE_GENAI_MODEL_FAST ?? "gemini-3.5-flash-lite",
|
|
@@ -1,2 +1,7 @@
|
|
|
1
|
-
import type { CheckRule } from "./types.ts";
|
|
1
|
+
import type { CheckContext, CheckRule } from "./types.ts";
|
|
2
|
+
export declare const TITLE_MIN = 20;
|
|
3
|
+
export declare const TITLE_MAX = 60;
|
|
4
|
+
export declare const DESCRIPTION_MIN = 70;
|
|
5
|
+
export declare const DESCRIPTION_MAX = 160;
|
|
6
|
+
export declare function effectiveTitle(page: CheckContext["page"]): string;
|
|
2
7
|
export declare const DRAFT_RULES: CheckRule[];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { IMAGE_PLACEHOLDER, isKnownRoute, normalizeLinkPath, parseLink, toAltPath } from "@avocadostudio-ai/shared";
|
|
2
2
|
import { fieldText, groupByBlock } from "./field-walk.js";
|
|
3
|
+
import { I18N_RULES } from "./rules-i18n.js";
|
|
3
4
|
/*
|
|
4
5
|
* The eleven-ish draft-tier rules. Each is a pure function; none does IO.
|
|
5
6
|
*
|
|
@@ -9,12 +10,12 @@ import { fieldText, groupByBlock } from "./field-walk.js";
|
|
|
9
10
|
* warning — this is very likely wrong
|
|
10
11
|
* info — worth a look, and safe to ignore forever
|
|
11
12
|
*/
|
|
12
|
-
const TITLE_MIN = 20;
|
|
13
|
-
const TITLE_MAX = 60;
|
|
14
|
-
const DESCRIPTION_MIN = 70;
|
|
15
|
-
const DESCRIPTION_MAX = 160;
|
|
13
|
+
export const TITLE_MIN = 20;
|
|
14
|
+
export const TITLE_MAX = 60;
|
|
15
|
+
export const DESCRIPTION_MIN = 70;
|
|
16
|
+
export const DESCRIPTION_MAX = 160;
|
|
16
17
|
const THIN_CONTENT_CHARS = 120;
|
|
17
|
-
function effectiveTitle(page) {
|
|
18
|
+
export function effectiveTitle(page) {
|
|
18
19
|
return (page.meta?.title ?? page.title ?? "").trim();
|
|
19
20
|
}
|
|
20
21
|
function nonEmpty(value) {
|
|
@@ -389,7 +390,7 @@ const altMissing = {
|
|
|
389
390
|
key: `${image.blockId}:${image.path}`,
|
|
390
391
|
title: `Image has no alt text`,
|
|
391
392
|
detail: `${image.label ?? image.path} is set but its alt text is empty.`,
|
|
392
|
-
evidence: evidenceFor(alt)
|
|
393
|
+
evidence: { ...evidenceFor(alt), imageUrl: String(image.value) }
|
|
393
394
|
});
|
|
394
395
|
}
|
|
395
396
|
}
|
|
@@ -435,5 +436,7 @@ export const DRAFT_RULES = [
|
|
|
435
436
|
internalLinkDead,
|
|
436
437
|
fileLinkUnknown,
|
|
437
438
|
altMissing,
|
|
438
|
-
unfinished
|
|
439
|
+
unfinished,
|
|
440
|
+
// Translation drift's rules: silent on a site with one language.
|
|
441
|
+
...I18N_RULES
|
|
439
442
|
];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type BlockInstance, type PageDoc } from "@avocadostudio-ai/shared";
|
|
2
|
+
import type { CheckContext, CheckRule } from "./types.ts";
|
|
3
|
+
type Pair = {
|
|
4
|
+
page: PageDoc;
|
|
5
|
+
source: PageDoc;
|
|
6
|
+
locale: string;
|
|
7
|
+
sourceLocale: string;
|
|
8
|
+
};
|
|
9
|
+
/** The source-language counterpart of a translated page, or null for a page that is its own source. */
|
|
10
|
+
export declare function translationPair(ctx: Pick<CheckContext, "page" | "site">): Pair | null;
|
|
11
|
+
/**
|
|
12
|
+
* Pair blocks by type in order: each block of the translation takes the next
|
|
13
|
+
* unpaired source block of the same type. A block with no partner is what
|
|
14
|
+
* i18n.structure reports.
|
|
15
|
+
*/
|
|
16
|
+
export declare function pairBlocks(source: BlockInstance[], translation: BlockInstance[]): {
|
|
17
|
+
pairs: {
|
|
18
|
+
source: BlockInstance;
|
|
19
|
+
translation: BlockInstance;
|
|
20
|
+
}[];
|
|
21
|
+
missing: BlockInstance[];
|
|
22
|
+
extra: BlockInstance[];
|
|
23
|
+
};
|
|
24
|
+
export declare const I18N_RULES: CheckRule[];
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { counterpartRoute, groupRoutesByLocale, localeOfRoute } from "@avocadostudio-ai/shared";
|
|
2
|
+
import { fieldText, walkPageFields } from "./field-walk.js";
|
|
3
|
+
/*
|
|
4
|
+
* Translation drift (site-ops-agents.md §1, §8 item 13): a page in another
|
|
5
|
+
* language that no longer says what its source-language page says.
|
|
6
|
+
*
|
|
7
|
+
* A translated page is compared with its counterpart in the site's source
|
|
8
|
+
* language — the unprefixed root, or the declared default. Blocks are paired
|
|
9
|
+
* by position and type, not by id: a translation made by copying a page gets
|
|
10
|
+
* new block ids, but keeps the source's order.
|
|
11
|
+
*
|
|
12
|
+
* Three rules, all deterministic, none needing history:
|
|
13
|
+
* - i18n.structure — the pages no longer have the same blocks: one was
|
|
14
|
+
* added to the source and never to the translation,
|
|
15
|
+
* or the other way round;
|
|
16
|
+
* - i18n.untranslated — a text field still holds the source's words,
|
|
17
|
+
* copied and never translated;
|
|
18
|
+
* - i18n.source-newer — the source page was edited after the translation
|
|
19
|
+
* was last touched, so something may be missing.
|
|
20
|
+
*
|
|
21
|
+
* A site with one language, or none detectable from its slugs, gets nothing.
|
|
22
|
+
*/
|
|
23
|
+
/** Text shorter than this is too often the same in both languages (a name, "FAQ", "OK"). */
|
|
24
|
+
const UNTRANSLATED_MIN_CHARS = 25;
|
|
25
|
+
/** The source is newer by at least this much before it counts: saves and seeds land within seconds. */
|
|
26
|
+
const SOURCE_NEWER_MARGIN_MS = 60 * 60 * 1000;
|
|
27
|
+
const FINDINGS_PER_PAGE = 20;
|
|
28
|
+
/** The source-language counterpart of a translated page, or null for a page that is its own source. */
|
|
29
|
+
export function translationPair(ctx) {
|
|
30
|
+
const lookup = ctx.site.page;
|
|
31
|
+
if (!lookup)
|
|
32
|
+
return null;
|
|
33
|
+
const routes = groupRoutesByLocale(ctx.site.slugs);
|
|
34
|
+
if (!routes)
|
|
35
|
+
return null;
|
|
36
|
+
const declared = ctx.site.config.defaultLocale?.trim().toLowerCase().split("-")[0];
|
|
37
|
+
const sourceLocale = declared && routes.byLocale[declared] ? declared : routes.locales[0];
|
|
38
|
+
if (!sourceLocale)
|
|
39
|
+
return null;
|
|
40
|
+
const locale = localeOfRoute(ctx.page.slug, routes);
|
|
41
|
+
if (!locale || locale === sourceLocale)
|
|
42
|
+
return null;
|
|
43
|
+
const sourceSlug = counterpartRoute(ctx.page.slug, sourceLocale, routes);
|
|
44
|
+
const source = sourceSlug ? lookup(sourceSlug) : undefined;
|
|
45
|
+
if (!source)
|
|
46
|
+
return null;
|
|
47
|
+
return { page: ctx.page, source, locale, sourceLocale };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Pair blocks by type in order: each block of the translation takes the next
|
|
51
|
+
* unpaired source block of the same type. A block with no partner is what
|
|
52
|
+
* i18n.structure reports.
|
|
53
|
+
*/
|
|
54
|
+
export function pairBlocks(source, translation) {
|
|
55
|
+
const pairs = [];
|
|
56
|
+
const used = new Set();
|
|
57
|
+
const unmatched = [];
|
|
58
|
+
let cursor = 0;
|
|
59
|
+
for (const block of translation) {
|
|
60
|
+
let found = -1;
|
|
61
|
+
for (let i = cursor; i < source.length; i++) {
|
|
62
|
+
if (!used.has(i) && source[i].type === block.type) {
|
|
63
|
+
found = i;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (found === -1) {
|
|
68
|
+
unmatched.push(block);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
used.add(found);
|
|
72
|
+
cursor = found + 1;
|
|
73
|
+
pairs.push({ source: source[found], translation: block });
|
|
74
|
+
}
|
|
75
|
+
const missing = source.filter((_, i) => !used.has(i));
|
|
76
|
+
return { pairs, missing, extra: unmatched };
|
|
77
|
+
}
|
|
78
|
+
function label(block) {
|
|
79
|
+
const props = block.props;
|
|
80
|
+
for (const key of ["heading", "title", "label"]) {
|
|
81
|
+
const value = props[key];
|
|
82
|
+
if (typeof value === "string" && value.trim())
|
|
83
|
+
return `${block.type} “${value.trim().slice(0, 40)}”`;
|
|
84
|
+
}
|
|
85
|
+
return block.type;
|
|
86
|
+
}
|
|
87
|
+
const structure = {
|
|
88
|
+
id: "i18n.structure",
|
|
89
|
+
agent: "translation-drift",
|
|
90
|
+
severity: "warning",
|
|
91
|
+
run: (ctx) => {
|
|
92
|
+
const pair = translationPair(ctx);
|
|
93
|
+
if (!pair)
|
|
94
|
+
return [];
|
|
95
|
+
const { missing, extra } = pairBlocks(pair.source.blocks, pair.page.blocks);
|
|
96
|
+
const out = [];
|
|
97
|
+
if (missing.length > 0) {
|
|
98
|
+
out.push({
|
|
99
|
+
key: "missing",
|
|
100
|
+
title: `${missing.length} block${missing.length === 1 ? "" : "s"} on ${pair.source.slug} ${missing.length === 1 ? "is" : "are"} missing here`,
|
|
101
|
+
detail: `Not on this ${pair.locale} page: ${missing.map(label).join(", ")}.`,
|
|
102
|
+
evidence: { source: "draft", locale: pair.locale }
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (extra.length > 0) {
|
|
106
|
+
out.push({
|
|
107
|
+
key: "extra",
|
|
108
|
+
severity: "info",
|
|
109
|
+
title: `${extra.length} block${extra.length === 1 ? "" : "s"} here ${extra.length === 1 ? "is" : "are"} not on ${pair.source.slug}`,
|
|
110
|
+
detail: `Only on this ${pair.locale} page: ${extra.map(label).join(", ")}. Removed from the source, or added here on purpose?`,
|
|
111
|
+
evidence: { source: "draft", locale: pair.locale }
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
/** Words, not a URL, a number or a code: something a translator would have changed. */
|
|
118
|
+
function isTranslatableText(text) {
|
|
119
|
+
if (text.length < UNTRANSLATED_MIN_CHARS)
|
|
120
|
+
return false;
|
|
121
|
+
if (/^(https?:\/\/|\/|#|mailto:|tel:)/i.test(text))
|
|
122
|
+
return false;
|
|
123
|
+
return (text.match(/\p{L}/gu) ?? []).length >= 12 && /\s/.test(text);
|
|
124
|
+
}
|
|
125
|
+
const untranslated = {
|
|
126
|
+
id: "i18n.untranslated",
|
|
127
|
+
agent: "translation-drift",
|
|
128
|
+
severity: "warning",
|
|
129
|
+
run: (ctx) => {
|
|
130
|
+
const pair = translationPair(ctx);
|
|
131
|
+
if (!pair)
|
|
132
|
+
return [];
|
|
133
|
+
const { pairs } = pairBlocks(pair.source.blocks, pair.page.blocks);
|
|
134
|
+
const sourceFields = new Map();
|
|
135
|
+
for (const entry of walkPageFields(pair.source, ctx.manifest)) {
|
|
136
|
+
const list = sourceFields.get(entry.blockId) ?? [];
|
|
137
|
+
list.push(entry);
|
|
138
|
+
sourceFields.set(entry.blockId, list);
|
|
139
|
+
}
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const { source, translation } of pairs) {
|
|
142
|
+
const bySourcePath = new Map((sourceFields.get(source.id) ?? []).map((f) => [f.path, f]));
|
|
143
|
+
for (const field of ctx.fields) {
|
|
144
|
+
if (field.blockId !== translation.id || (field.kind !== "text" && field.kind !== "richtext"))
|
|
145
|
+
continue;
|
|
146
|
+
const text = fieldText(field.value).replace(/\s+/g, " ").trim();
|
|
147
|
+
const sourceText = fieldText(bySourcePath.get(field.path)?.value).replace(/\s+/g, " ").trim();
|
|
148
|
+
if (!text || text !== sourceText || !isTranslatableText(text))
|
|
149
|
+
continue;
|
|
150
|
+
out.push({
|
|
151
|
+
key: `${field.blockId}:${field.path}`,
|
|
152
|
+
title: `Still in the source language: ${field.label ?? field.path}`,
|
|
153
|
+
detail: `“${text.slice(0, 80)}${text.length > 80 ? "…" : ""}” is the same as on ${pair.source.slug}.`,
|
|
154
|
+
evidence: {
|
|
155
|
+
source: "draft",
|
|
156
|
+
blockId: field.blockId,
|
|
157
|
+
blockType: field.blockType,
|
|
158
|
+
...(field.blockLabel ? { blockLabel: field.blockLabel } : {}),
|
|
159
|
+
path: field.path,
|
|
160
|
+
locale: pair.locale,
|
|
161
|
+
sourceText: text
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
if (out.length >= FINDINGS_PER_PAGE)
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const sourceNewer = {
|
|
172
|
+
id: "i18n.source-newer",
|
|
173
|
+
agent: "translation-drift",
|
|
174
|
+
severity: "info",
|
|
175
|
+
run: (ctx) => {
|
|
176
|
+
const pair = translationPair(ctx);
|
|
177
|
+
if (!pair)
|
|
178
|
+
return [];
|
|
179
|
+
const sourceAt = Date.parse(pair.source.updatedAt);
|
|
180
|
+
const pageAt = Date.parse(pair.page.updatedAt);
|
|
181
|
+
if (Number.isNaN(sourceAt) || Number.isNaN(pageAt) || sourceAt - pageAt < SOURCE_NEWER_MARGIN_MS)
|
|
182
|
+
return [];
|
|
183
|
+
const day = (at) => new Date(at).toISOString().slice(0, 10);
|
|
184
|
+
return [
|
|
185
|
+
{
|
|
186
|
+
title: `${pair.source.slug} was edited after this page`,
|
|
187
|
+
detail: `The source page changed on ${day(sourceAt)}; this ${pair.locale} page was last changed on ${day(pageAt)}. Check it still says the same.`,
|
|
188
|
+
evidence: { source: "draft", locale: pair.locale }
|
|
189
|
+
}
|
|
190
|
+
];
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
export const I18N_RULES = [structure, untranslated, sourceNewer];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { BlockManifest, PageDoc, SiteConfig } from "@avocadostudio-ai/shared";
|
|
2
|
-
import type { CheckRunRecord, CheckRunTrigger, DurableStore } from "../durable/types.ts";
|
|
2
|
+
import type { CheckRunRecord, CheckRunTrigger, DurableStore, FindingInput } from "../durable/types.ts";
|
|
3
3
|
import type { CheckRule, SiteAsset } from "./types.ts";
|
|
4
4
|
/**
|
|
5
5
|
* The fingerprint: identity of a problem, not of an occurrence of it.
|
|
@@ -35,8 +35,23 @@ export type RunChecksArgs = {
|
|
|
35
35
|
*/
|
|
36
36
|
assets?: SiteAsset[];
|
|
37
37
|
rules?: CheckRule[];
|
|
38
|
+
/** The ledger's agent. Defaults to the single rule's agent, else "checks". */
|
|
39
|
+
agent?: string;
|
|
38
40
|
store?: DurableStore;
|
|
39
41
|
runId?: string;
|
|
40
42
|
now?: () => number;
|
|
41
43
|
};
|
|
44
|
+
export type EvaluatedChecks = {
|
|
45
|
+
findings: FindingInput[];
|
|
46
|
+
scannedSlugs: string[];
|
|
47
|
+
/** The first rule that threw, if any did. The others still ran. */
|
|
48
|
+
error?: string;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* The rules over a site, with no store and no run ledger — pure in-memory work
|
|
52
|
+
* over the pages given. `runDraftChecks` records what this returns; the first
|
|
53
|
+
* run's backtest and the re-check before a fix is applied use it directly,
|
|
54
|
+
* because neither may write a finding.
|
|
55
|
+
*/
|
|
56
|
+
export declare function evaluateDraftChecks(args: Omit<RunChecksArgs, "store" | "runId" | "now" | "trigger">): EvaluatedChecks;
|
|
42
57
|
export declare function runDraftChecks(args: RunChecksArgs): Promise<CheckRunRecord>;
|