paseo-prompt-kit 0.5.2

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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +249 -0
  3. package/client/actions/enabled.ts +30 -0
  4. package/client/commands/rewrite-command.ts +54 -0
  5. package/client/composer-bridge/adapter.ts +15 -0
  6. package/client/composer-bridge/dom.ts +101 -0
  7. package/client/composer-bridge/effect.ts +64 -0
  8. package/client/composer-bridge/fiber.ts +97 -0
  9. package/client/composer-bridge/web.ts +58 -0
  10. package/client/icon.ts +13 -0
  11. package/client/pills/agent-pills.ts +207 -0
  12. package/client/pills/rewrite-runner.ts +123 -0
  13. package/client/settings/action-samples.ts +102 -0
  14. package/client/settings/api-endpoints.ts +156 -0
  15. package/client/settings/custom-actions.ts +79 -0
  16. package/client/settings/draft.ts +82 -0
  17. package/client/settings/model-filter.ts +33 -0
  18. package/client/settings/read-settings.ts +45 -0
  19. package/client/settings/readiness.ts +84 -0
  20. package/client/settings/sections/actions-section.tsx +75 -0
  21. package/client/settings/sections/advanced-section.tsx +127 -0
  22. package/client/settings/sections/api-endpoint-section.tsx +388 -0
  23. package/client/settings/sections/custom-actions-section.tsx +163 -0
  24. package/client/settings/sections/dedicated-model-section.tsx +136 -0
  25. package/client/settings/sections/engine-section.tsx +101 -0
  26. package/client/settings/sections/provider-map-card.tsx +89 -0
  27. package/client/settings/sections/stored-key-rows.tsx +106 -0
  28. package/client/settings/selection.ts +46 -0
  29. package/client/settings/settings-saved.ts +17 -0
  30. package/client/settings/settings-screen.tsx +197 -0
  31. package/client/settings/ui/button.tsx +56 -0
  32. package/client/settings/ui/notice.tsx +61 -0
  33. package/client/settings/ui/split-select.tsx +26 -0
  34. package/client/settings/ui/status-bar.tsx +89 -0
  35. package/client/settings/ui/tokens.ts +38 -0
  36. package/client/settings/validation.ts +50 -0
  37. package/client/sheet/rewrite-sheet.tsx +249 -0
  38. package/index.client.tsx +71 -0
  39. package/index.server.ts +98 -0
  40. package/package.json +53 -0
  41. package/paseo-plugin.json +6 -0
  42. package/server/log.ts +20 -0
  43. package/server/model-resolver/provider-catalog.ts +37 -0
  44. package/server/model-resolver/resolver.ts +196 -0
  45. package/server/paseo-types.ts +13 -0
  46. package/server/rewrite-engine/engine.ts +88 -0
  47. package/server/rewrite-engine/handler.ts +94 -0
  48. package/server/rewrite-engine/output-validator.ts +130 -0
  49. package/server/transports/api/anthropic.ts +61 -0
  50. package/server/transports/api/cloudflare.ts +52 -0
  51. package/server/transports/api/gemini.ts +62 -0
  52. package/server/transports/api/key.ts +95 -0
  53. package/server/transports/api/openai.ts +52 -0
  54. package/server/transports/api/protocol.ts +96 -0
  55. package/server/transports/api/runner.ts +284 -0
  56. package/server/transports/api/secrets-store.ts +90 -0
  57. package/server/transports/cli/family.ts +216 -0
  58. package/server/transports/cli/process.ts +118 -0
  59. package/server/transports/cli/runner.ts +89 -0
  60. package/shared/action-registry/loader.ts +63 -0
  61. package/shared/action-registry/registry.ts +47 -0
  62. package/shared/action-registry/rewrite-contract.ts +31 -0
  63. package/shared/action-registry/schema.ts +65 -0
  64. package/shared/action-registry/wrapper.ts +30 -0
  65. package/shared/api-protocol.ts +56 -0
  66. package/shared/cli-families.ts +29 -0
  67. package/shared/language-registry/loader.ts +53 -0
  68. package/shared/language-registry/registry.ts +20 -0
  69. package/shared/language-registry/schema.ts +21 -0
  70. package/shared/languages/en.json +6 -0
  71. package/shared/languages/index.ts +5 -0
  72. package/shared/languages/vi.json +6 -0
  73. package/shared/packs/general.json +17 -0
  74. package/shared/packs/index.ts +12 -0
  75. package/shared/protected-literals.ts +550 -0
  76. package/shared/rpc.ts +187 -0
  77. package/shared/settings.ts +90 -0
package/server/log.ts ADDED
@@ -0,0 +1,20 @@
1
+ const PREFIX = "[prompt-kit]";
2
+
3
+ /**
4
+ * Plugin stdout is captured by the daemon and shown by `paseo plugin logs`.
5
+ * Only non-content fields are logged: prompts and rewritten text never reach it.
6
+ */
7
+ export const pluginLog = {
8
+ info(fields: Record<string, string | number | null>, message: string): void {
9
+ console.log(`${PREFIX} ${message} ${format(fields)}`);
10
+ },
11
+ error(fields: Record<string, string | number | null>, message: string): void {
12
+ console.error(`${PREFIX} ${message} ${format(fields)}`);
13
+ },
14
+ };
15
+
16
+ function format(fields: Record<string, string | number | null>): string {
17
+ return Object.entries(fields)
18
+ .map(([key, value]) => `${key}=${value === null ? "-" : String(value)}`)
19
+ .join(" ");
20
+ }
@@ -0,0 +1,37 @@
1
+ import type { ProviderCatalogOutput } from "../../shared/rpc.js";
2
+ import type { PaseoApi } from "../paseo-types.js";
3
+
4
+ export type ProviderCatalog = ProviderCatalogOutput["providers"];
5
+ export type CatalogProvider = ProviderCatalog[number];
6
+ export type CatalogModel = CatalogProvider["models"][number];
7
+
8
+ /**
9
+ * Providers are reported available only when the daemon's own availability probe
10
+ * says so. A provider missing from that probe is treated as unavailable.
11
+ */
12
+ export async function readProviderCatalog(paseo: PaseoApi, cwd?: string): Promise<ProviderCatalog> {
13
+ const options = cwd === undefined ? undefined : { cwd };
14
+ const [snapshot, availability] = await Promise.all([
15
+ paseo.providers.snapshot(options),
16
+ paseo.providers.listAvailable(),
17
+ ]);
18
+ const available = new Map(
19
+ availability.providers.map((entry) => [entry.provider, entry.available]),
20
+ );
21
+ return snapshot.entries
22
+ .map((entry) => ({
23
+ provider: entry.provider,
24
+ label: entry.label ?? entry.provider,
25
+ available: available.get(entry.provider) === true,
26
+ models: (entry.models ?? []).map((model) => ({
27
+ id: model.id,
28
+ label: model.label,
29
+ thinkingOptions: (model.thinkingOptions ?? []).map((option) => ({
30
+ id: option.id,
31
+ label: option.label,
32
+ })),
33
+ defaultThinkingOptionId: model.defaultThinkingOptionId ?? null,
34
+ })),
35
+ }))
36
+ .sort((left, right) => left.provider.localeCompare(right.provider));
37
+ }
@@ -0,0 +1,196 @@
1
+ import type { ApiEndpoint } from "../../shared/api-protocol.js";
2
+ import type { RewriteError } from "../../shared/rpc.js";
3
+ import type { PromptKitSettings } from "../../shared/settings.js";
4
+ import type { PaseoApi } from "../paseo-types.js";
5
+ import { resolveFamily, type CliFamily } from "../transports/cli/family.js";
6
+ import { readProviderCatalog } from "./provider-catalog.js";
7
+
8
+ /** Resolves provider/model/thinking and transport for one request. Runs nothing. */
9
+
10
+ export interface ResolvedModel {
11
+ readonly provider: string;
12
+ readonly model: string | null;
13
+ readonly thinkingOptionId: string | null;
14
+ }
15
+
16
+ export type ResolvedTarget =
17
+ | { ok: true; via: "cli"; family: CliFamily; model: ResolvedModel; cwd: string }
18
+ | { ok: true; via: "api"; endpoint: ApiEndpoint; model: string; reported: ResolvedModel }
19
+ | { ok: false; error: RewriteError };
20
+
21
+ /** What the resolver needs to know about the agent whose pill was pressed. */
22
+ interface AgentModelSnapshot {
23
+ readonly provider: string;
24
+ readonly model: string | null;
25
+ readonly thinkingOptionId: string | null;
26
+ readonly cwd: string;
27
+ }
28
+
29
+ /** `provider/model` selector → provider. */
30
+ function providerOf(selector: string): string {
31
+ const separator = selector.indexOf("/");
32
+ return separator === -1 ? selector : selector.slice(0, separator);
33
+ }
34
+
35
+ /** Runtime model first, like the host's `resolvePreferredModelId`. */
36
+ function runtimeModel(agent: { runtimeInfo?: { model?: string | null } | null }): string | null {
37
+ const model = agent.runtimeInfo?.model;
38
+ return typeof model === "string" && model.trim() !== "" ? model : null;
39
+ }
40
+
41
+ /** Reads the agent without requiring a CLI family; only the current-CLI path needs one. */
42
+ async function readAgent(paseo: PaseoApi, agentId: string): Promise<AgentModelSnapshot | null> {
43
+ const refreshed = await paseo.agents.ref(agentId).refresh();
44
+ if (!refreshed) return null;
45
+ const agent = refreshed.agent;
46
+ return {
47
+ provider: providerOf(agent.runtimeInfo?.provider ?? agent.provider),
48
+ model: runtimeModel(agent) ?? agent.model,
49
+ // `effectiveThinkingOptionId` is the host's own resolution: the runtime
50
+ // option when the session reported one, otherwise the configured option.
51
+ thinkingOptionId: agent.effectiveThinkingOptionId ?? agent.thinkingOptionId ?? null,
52
+ cwd: agent.cwd,
53
+ };
54
+ }
55
+
56
+ function invalidSelection(message: string): { ok: false; error: RewriteError } {
57
+ return { ok: false, error: { code: "invalid_selection", message } };
58
+ }
59
+
60
+ function invalidModel(message: string): { ok: false; error: RewriteError } {
61
+ return { ok: false, error: { code: "invalid_model", message } };
62
+ }
63
+
64
+ function unsupported(provider: string): { ok: false; error: RewriteError } {
65
+ return {
66
+ ok: false,
67
+ error: {
68
+ code: "unsupported_provider",
69
+ message: `No rewrite CLI is configured for provider "${provider}".`,
70
+ },
71
+ };
72
+ }
73
+
74
+ const AGENT_GONE = "The current agent is no longer available.";
75
+ const NO_AGENT_YET =
76
+ "This Composer has no agent yet, so there is no current model to use. Choose a dedicated model or Direct API in PromptKit settings, or send the first message and use the PromptKit pill.";
77
+
78
+ /** Path 1: the agent's own provider CLI, with the model the Composer shows. */
79
+ function resolveCurrentCli(agent: AgentModelSnapshot, settings: PromptKitSettings): ResolvedTarget {
80
+ const family = resolveFamily(agent.provider, settings.providerCli);
81
+ if (family === null) return unsupported(agent.provider);
82
+ if (agent.model === null || agent.model === "") {
83
+ return invalidSelection(`The agent has no model selected for provider "${agent.provider}".`);
84
+ }
85
+ return {
86
+ ok: true,
87
+ via: "cli",
88
+ family,
89
+ model: { provider: agent.provider, model: agent.model, thinkingOptionId: agent.thinkingOptionId },
90
+ cwd: agent.cwd,
91
+ };
92
+ }
93
+
94
+ /** Path 2: dedicated provider CLI, checked against the live catalog. */
95
+ async function resolveDedicatedCli(
96
+ paseo: PaseoApi,
97
+ settings: PromptKitSettings,
98
+ cwd: string | undefined,
99
+ ): Promise<ResolvedTarget> {
100
+ const provider = settings.dedicatedProvider;
101
+ const model = settings.dedicatedModel;
102
+ if (provider === null || model === null) {
103
+ return invalidSelection("No dedicated provider and model are selected in PromptKit settings.");
104
+ }
105
+ const family = resolveFamily(provider, settings.providerCli);
106
+ if (family === null) return unsupported(provider);
107
+
108
+ let catalog: Awaited<ReturnType<typeof readProviderCatalog>>;
109
+ try {
110
+ catalog = await readProviderCatalog(paseo, cwd);
111
+ } catch (error) {
112
+ return invalidModel(
113
+ `Could not read the provider catalog: ${error instanceof Error ? error.message : String(error)}`,
114
+ );
115
+ }
116
+ const entry = catalog.find((candidate) => candidate.provider === provider);
117
+ if (!entry || !entry.available) return invalidModel(`Provider is unavailable: ${provider}`);
118
+ const selected = entry.models.find((candidate) => candidate.id === model);
119
+ if (!selected) return invalidModel(`Model is unavailable: ${provider}/${model}`);
120
+ const thinking = settings.dedicatedThinkingOptionId;
121
+ if (thinking !== null && !selected.thinkingOptions.some((option) => option.id === thinking)) {
122
+ return invalidModel(`Thinking option is unavailable: ${provider}/${model} ${thinking}`);
123
+ }
124
+ return {
125
+ ok: true,
126
+ via: "cli",
127
+ family,
128
+ model: { provider, model, thinkingOptionId: thinking },
129
+ cwd: cwd ?? "",
130
+ };
131
+ }
132
+
133
+ /** Path 3: API endpoint. A mapped provider sends its agent's model; otherwise the selected endpoint and model. `modelMode` is not read. */
134
+ function resolveApi(agent: AgentModelSnapshot | null, settings: PromptKitSettings): ResolvedTarget {
135
+ const mappedEndpointId = agent === null ? undefined : settings.apiEndpointByProvider[agent.provider];
136
+ const viaProviderMapping = mappedEndpointId !== undefined;
137
+
138
+ const endpointId = viaProviderMapping ? mappedEndpointId : settings.apiEndpointId;
139
+ if (endpointId === null) {
140
+ return invalidSelection(
141
+ agent === null
142
+ ? "No API endpoint is selected in PromptKit settings."
143
+ : `No API endpoint is selected in PromptKit settings, and provider "${agent.provider}" is not mapped to one.`,
144
+ );
145
+ }
146
+ const endpoint = settings.apiEndpoints.find((candidate) => candidate.id === endpointId);
147
+ if (endpoint === undefined) {
148
+ return {
149
+ ok: false,
150
+ error: {
151
+ code: "api_endpoint_unknown",
152
+ message: `No API endpoint is configured with the id "${endpointId}".`,
153
+ },
154
+ };
155
+ }
156
+
157
+ const model = viaProviderMapping && agent !== null ? agent.model : settings.apiModel;
158
+ if (model === null || model.trim() === "") {
159
+ return invalidSelection(
160
+ viaProviderMapping && agent !== null
161
+ ? `The agent has no model selected for provider "${agent.provider}".`
162
+ : "No API model is selected in PromptKit settings.",
163
+ );
164
+ }
165
+ if (endpoint.models.length > 0 && !endpoint.models.includes(model)) {
166
+ return invalidModel(`Model is unavailable on endpoint "${endpoint.id}": ${model}`);
167
+ }
168
+
169
+ return {
170
+ ok: true,
171
+ via: "api",
172
+ endpoint,
173
+ model,
174
+ reported: { provider: endpoint.id, model, thinkingOptionId: null },
175
+ };
176
+ }
177
+
178
+ export async function resolveTarget(
179
+ paseo: PaseoApi,
180
+ agentId: string | null,
181
+ settings: PromptKitSettings,
182
+ ): Promise<ResolvedTarget> {
183
+ // Draft Composer: only agent-dependent paths refuse.
184
+ if (agentId === null) {
185
+ if (settings.transport === "api") return resolveApi(null, settings);
186
+ if (settings.modelMode !== "dedicated") return invalidSelection(NO_AGENT_YET);
187
+ return resolveDedicatedCli(paseo, settings, undefined);
188
+ }
189
+
190
+ const agent = await readAgent(paseo, agentId);
191
+ if (agent === null) return invalidSelection(AGENT_GONE);
192
+
193
+ if (settings.transport === "api") return resolveApi(agent, settings);
194
+ if (settings.modelMode === "dedicated") return resolveDedicatedCli(paseo, settings, agent.cwd);
195
+ return resolveCurrentCli(agent, settings);
196
+ }
@@ -0,0 +1,13 @@
1
+ import type { PluginHandlerContext } from "@getpaseo/plugin/server";
2
+
3
+ /**
4
+ * The daemon bundles the server entry from a managed checkout that has no
5
+ * `node_modules`; only the host SDK specifiers resolve there, and the client
6
+ * package is not one of them. The Paseo types are therefore derived from the
7
+ * SDK the host passes into a handler, never imported from the client package.
8
+ */
9
+ export type PaseoApi = PluginHandlerContext["paseo"];
10
+
11
+ export type PaseoAgentHandle = Awaited<
12
+ ReturnType<ReturnType<PaseoApi["workspaces"]["ref"]>["agents"]["create"]>
13
+ >;
@@ -0,0 +1,88 @@
1
+ import type { RewriteOutput } from "../../shared/rpc.js";
2
+ import type { PromptKitSettings } from "../../shared/settings.js";
3
+ import { resolveTarget } from "../model-resolver/resolver.js";
4
+ import type { PaseoApi } from "../paseo-types.js";
5
+ import { runApiRewrite } from "../transports/api/runner.js";
6
+ import type { CliSpawner } from "../transports/cli/process.js";
7
+ import { runCliRewrite } from "../transports/cli/runner.js";
8
+ import { validateRewriteOutput } from "./output-validator.js";
9
+
10
+ export interface RewriteRequest {
11
+ /** Null for a Composer that has no agent yet. */
12
+ agentId: string | null;
13
+ workspaceId: string;
14
+ systemPrompt: string;
15
+ /** The user's own prompt; protected-literal validation runs against this, not the wrapper. */
16
+ originalPrompt: string;
17
+ taskPrompt: string;
18
+ }
19
+
20
+ export interface RewriteDependencies {
21
+ settings: PromptKitSettings;
22
+ /** Overrides the request timeout; tests use it to exercise the timeout path. */
23
+ timeoutMs?: number;
24
+ /** Test seam: replaces the process spawner under the CLI runner. */
25
+ spawn?: CliSpawner;
26
+ /** Test seam: replaces the HTTP call under the API runner. */
27
+ fetch?: typeof globalThis.fetch;
28
+ /** Test seam: replaces the environment an API key is read from. */
29
+ env?: NodeJS.ProcessEnv;
30
+ }
31
+
32
+ /** resolve target → run transport → validate. Every failure is a typed error. */
33
+ export async function runRewrite(
34
+ paseo: PaseoApi,
35
+ request: RewriteRequest,
36
+ dependencies: RewriteDependencies,
37
+ ): Promise<RewriteOutput> {
38
+ const startedAt = Date.now();
39
+ const settings = dependencies.settings;
40
+ const timeoutMs = dependencies.timeoutMs ?? settings.timeoutMs;
41
+
42
+ const target = await resolveTarget(paseo, request.agentId, settings);
43
+ if (!target.ok) return { status: "error", error: target.error };
44
+
45
+ const generated =
46
+ target.via === "api"
47
+ ? await runApiRewrite(
48
+ {
49
+ endpoint: target.endpoint,
50
+ model: target.model,
51
+ systemPrompt: request.systemPrompt,
52
+ taskPrompt: request.taskPrompt,
53
+ timeoutMs,
54
+ secretsDir: settings.secretsDir,
55
+ },
56
+ {
57
+ ...(dependencies.fetch === undefined ? {} : { fetch: dependencies.fetch }),
58
+ ...(dependencies.env === undefined ? {} : { env: dependencies.env }),
59
+ },
60
+ )
61
+ : await runCliRewrite(
62
+ {
63
+ family: target.family,
64
+ model: target.model.model ?? "",
65
+ thinkingOptionId: target.model.thinkingOptionId,
66
+ systemPrompt: request.systemPrompt,
67
+ taskPrompt: request.taskPrompt,
68
+ timeoutMs,
69
+ },
70
+ dependencies.spawn === undefined ? {} : { spawn: dependencies.spawn },
71
+ );
72
+ if (!generated.ok) {
73
+ return { status: "error", error: { code: generated.code, message: generated.message } };
74
+ }
75
+
76
+ const validated = validateRewriteOutput({
77
+ originalPrompt: request.originalPrompt,
78
+ output: generated.text,
79
+ });
80
+ if (!validated.ok) return { status: "error", error: validated.error };
81
+
82
+ return {
83
+ status: "ok",
84
+ rewrittenPrompt: validated.text,
85
+ model: target.via === "api" ? target.reported : target.model,
86
+ durationMs: Date.now() - startedAt,
87
+ };
88
+ }
@@ -0,0 +1,94 @@
1
+ import type { PluginHandlerContext } from "@getpaseo/plugin/server";
2
+ import { buildTaskPrompt } from "../../shared/action-registry/wrapper.js";
3
+ import { resolveAction } from "../../shared/action-registry/registry.js";
4
+ import { buildSystemPrompt } from "../../shared/action-registry/rewrite-contract.js";
5
+ import { resolveLanguage } from "../../shared/language-registry/registry.js";
6
+ import type { RewriteInput, RewriteOutput } from "../../shared/rpc.js";
7
+ import type { CliSpawner } from "../transports/cli/process.js";
8
+ import { pluginLog } from "../log.js";
9
+ import { runRewrite } from "./engine.js";
10
+
11
+ export interface RewriteHandlerDependencies {
12
+ /** Test seam: replaces the CLI spawner underneath the rewrite engine. */
13
+ spawn?: CliSpawner;
14
+ /** Test seam: replaces the HTTP call underneath the API runner. */
15
+ fetch?: typeof globalThis.fetch;
16
+ /** Test seam: replaces the environment an API key is read from. */
17
+ env?: NodeJS.ProcessEnv;
18
+ }
19
+
20
+ /**
21
+ * Builds the `prompt-kit.rewrite` handler.
22
+ *
23
+ * It owns one job: turn an RPC input into a `runRewrite` call and log the
24
+ * outcome without ever writing prompt or answer text. The spawner is a
25
+ * parameter rather than a module global so a test can drive the handler without
26
+ * launching a real CLI.
27
+ */
28
+ export function createRewriteHandler(dependencies: RewriteHandlerDependencies = {}) {
29
+ return async function handleRewrite(
30
+ input: RewriteInput,
31
+ { paseo }: PluginHandlerContext,
32
+ ): Promise<RewriteOutput> {
33
+ const action = resolveAction(input.actionId, input.settings.customActions);
34
+ if (action === null) {
35
+ pluginLog.error({ action: input.actionId }, "rewrite refused: unknown action");
36
+ return {
37
+ status: "error",
38
+ error: {
39
+ code: "unknown_action",
40
+ message: `No action pack provides "${input.actionId}".`,
41
+ },
42
+ };
43
+ }
44
+
45
+ const language = resolveLanguage(input.settings.outputLanguage);
46
+ if (language === undefined) {
47
+ pluginLog.error({ language: input.settings.outputLanguage }, "rewrite refused: unknown language");
48
+ return {
49
+ status: "error",
50
+ error: {
51
+ code: "invalid_selection",
52
+ message: `No output language is loaded with the id "${input.settings.outputLanguage}".`,
53
+ },
54
+ };
55
+ }
56
+
57
+ pluginLog.info(
58
+ { action: input.actionId, agentId: input.agentId, language: input.settings.outputLanguage },
59
+ "rewrite start",
60
+ );
61
+
62
+ const output = await runRewrite(
63
+ paseo,
64
+ {
65
+ agentId: input.agentId,
66
+ workspaceId: input.workspaceId,
67
+ systemPrompt: buildSystemPrompt(action),
68
+ originalPrompt: input.originalPrompt,
69
+ taskPrompt: buildTaskPrompt(action, input.originalPrompt, language?.instruction ?? null),
70
+ },
71
+ {
72
+ settings: input.settings,
73
+ ...(dependencies.spawn === undefined ? {} : { spawn: dependencies.spawn }),
74
+ ...(dependencies.fetch === undefined ? {} : { fetch: dependencies.fetch }),
75
+ ...(dependencies.env === undefined ? {} : { env: dependencies.env }),
76
+ },
77
+ );
78
+
79
+ if (output.status === "ok") {
80
+ pluginLog.info(
81
+ {
82
+ action: input.actionId,
83
+ provider: output.model.provider,
84
+ model: output.model.model,
85
+ durationMs: output.durationMs,
86
+ },
87
+ "rewrite success",
88
+ );
89
+ } else {
90
+ pluginLog.error({ action: input.actionId, code: output.error.code }, "rewrite failed");
91
+ }
92
+ return output;
93
+ };
94
+ }
@@ -0,0 +1,130 @@
1
+ import type { RewriteError } from "../../shared/rpc.js";
2
+ import { findMissingProtectedLiterals } from "../../shared/protected-literals.js";
3
+
4
+ export type OutputValidation =
5
+ | { ok: true; text: string }
6
+ | { ok: false; error: RewriteError };
7
+
8
+ /** Plan §19: a rewrite far longer than the request is a specification, not a rewrite. */
9
+ const MAX_OUTPUT_CHARS = 20_000;
10
+
11
+ const FENCE = /```/g;
12
+
13
+ /**
14
+ * Only unambiguous meta openers. A rewritten prompt may legitimately start with
15
+ * "This is broken" or "Output the result", so the vocabulary stays narrow.
16
+ */
17
+ const META_PREFACE =
18
+ /^(?:sure[,!.:]|certainly[,!.:]|of course[,!.:]|here(?:'s| is) (?:the |your )?(?:rewritten|improved|enhanced|optimized|revised) (?:prompt|version)|below is (?:the |your )?(?:rewritten|improved|enhanced|optimized|revised) (?:prompt|version)|the (?:rewritten|improved|enhanced|optimized|revised) (?:prompt|version) is[:.]?$|rewritten prompt:|improved prompt:)/i;
19
+
20
+ const REFUSAL =
21
+ /^(?:i (?:cannot|can't|won't|will not|am unable|am not able)|i'm sorry|sorry[,.]|as an ai|unfortunately)/i;
22
+
23
+ /**
24
+ * Output that talks about the prompt instead of rewriting it. An injection prompt is
25
+ * exactly the case where a model is tempted to answer with this commentary rather than
26
+ * the rewrite, so the first line is checked against the meta shapes.
27
+ */
28
+ const COMMENTARY =
29
+ /^(?:this (?:prompt|input|request|message) (?:is|looks like|appears to be|contains)|the (?:text|prompt|input|message) (?:below|above) (?:is|contains)|treat (?:this|it) as (?:untrusted|data)|it (?:is|looks like|appears to be) (?:a )?(?:prompt[- ]?injection|injection attempt)|(?:the )?(?:prompt|input)[- ]injection (?:attempt|detected)|i (?:notice|see|detect)|not a legitimate coding task|no action (?:is|was) taken)/i;
30
+
31
+ function countFences(text: string): number {
32
+ return (text.match(FENCE) ?? []).length;
33
+ }
34
+
35
+ /** True when the whole answer is one fenced block, the wrapper the prompt forbids. */
36
+ function isFullMarkdownEnvelope(text: string): boolean {
37
+ const trimmed = text.trim();
38
+ return trimmed.startsWith("```") && trimmed.endsWith("```") && countFences(trimmed) === 2;
39
+ }
40
+
41
+ function nonEmptyLines(text: string): string[] {
42
+ return text
43
+ .split("\n")
44
+ .map((line) => line.trim())
45
+ .filter((line) => line !== "");
46
+ }
47
+
48
+ function wordCount(text: string): number {
49
+ return text.split(/\s+/).filter(Boolean).length;
50
+ }
51
+
52
+ /** Literals can be whole code blocks; the message needs only enough to recognise them. */
53
+ function clip(value: string, max = 60): string {
54
+ const flat = value.replace(/\s+/g, " ").trim();
55
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
56
+ }
57
+
58
+ function fail(code: RewriteError["code"], message: string): OutputValidation {
59
+ return { ok: false, error: { code, message } };
60
+ }
61
+
62
+ /**
63
+ * Accepts only text that is the rewritten prompt itself: non-empty, fence-balanced,
64
+ * free of preface/envelope/refusal, and carrying every protected literal of the
65
+ * original. A rejected output never becomes a replacement text.
66
+ */
67
+ export function validateRewriteOutput(input: {
68
+ originalPrompt: string;
69
+ output: string;
70
+ }): OutputValidation {
71
+ const text = input.output.trim();
72
+ if (text === "") {
73
+ return fail("empty_output", "The rewrite agent returned empty output.");
74
+ }
75
+ if (text.length > MAX_OUTPUT_CHARS) {
76
+ return fail(
77
+ "generation_failed",
78
+ `The rewrite agent returned ${text.length} characters, over the ${MAX_OUTPUT_CHARS} character limit.`,
79
+ );
80
+ }
81
+ if (countFences(text) % 2 !== 0) {
82
+ return fail("generation_failed", "The rewrite output contains an unclosed code fence.");
83
+ }
84
+ if (isFullMarkdownEnvelope(text) && !isFullMarkdownEnvelope(input.originalPrompt)) {
85
+ return fail("generation_failed", "The rewrite output is wrapped in a markdown code fence.");
86
+ }
87
+
88
+ const lines = nonEmptyLines(text);
89
+ const first = lines[0];
90
+ if (
91
+ first !== undefined &&
92
+ META_PREFACE.test(first) &&
93
+ !input.originalPrompt.includes(first)
94
+ ) {
95
+ return fail("generation_failed", "The rewrite output starts with a preface, not the prompt.");
96
+ }
97
+ // A refusal is the whole answer: short, one or two lines, and not the user's text.
98
+ if (
99
+ first !== undefined &&
100
+ REFUSAL.test(first) &&
101
+ lines.length <= 2 &&
102
+ wordCount(text) <= 12 &&
103
+ !input.originalPrompt.includes(first)
104
+ ) {
105
+ return fail("generation_failed", "The rewrite agent refused to rewrite the prompt.");
106
+ }
107
+ // Commentary about the prompt is not the rewritten prompt, however long it is.
108
+ if (
109
+ first !== undefined &&
110
+ COMMENTARY.test(first) &&
111
+ !input.originalPrompt.includes(first)
112
+ ) {
113
+ return fail(
114
+ "generation_failed",
115
+ "The rewrite output comments on the prompt instead of rewriting it.",
116
+ );
117
+ }
118
+
119
+ const missing = findMissingProtectedLiterals(input.originalPrompt, text);
120
+ if (missing.length > 0) {
121
+ return fail(
122
+ "protected_literal_loss",
123
+ `The rewrite dropped ${missing.length} protected literal(s): ${missing
124
+ .slice(0, 5)
125
+ .map((literal) => `${literal.kind.replace("_", " ")} "${clip(literal.value)}"`)
126
+ .join(", ")}`,
127
+ );
128
+ }
129
+ return { ok: true, text };
130
+ }
@@ -0,0 +1,61 @@
1
+ import { at, authHeaders, joinUrl, stringFieldAt, type ApiCall, type ApiHttpRequest, type ApiModelsCall, type ApiProtocol } from "./protocol.js";
2
+
3
+ /**
4
+ * The Anthropic Messages protocol.
5
+ *
6
+ * Anthropic takes the instruction in a top-level `system` field rather than in
7
+ * the message list, requires `max_tokens`, and authenticates with `x-api-key`
8
+ * plus an explicit version header. It is also the shape z.ai, Alibaba/Qwen and
9
+ * most Anthropic-compatible gateways expose, so those are `baseUrl` entries.
10
+ *
11
+ * `max_tokens` is required here and cannot be omitted, so a rewrite budget is
12
+ * fixed at a value far above any rewritten prompt; the response stops on its own
13
+ * well before it.
14
+ */
15
+ const MAX_TOKENS = 4_096;
16
+
17
+ export const anthropicProtocol: ApiProtocol = {
18
+ id: "anthropic",
19
+ buildRequest(call: ApiCall): ApiHttpRequest {
20
+ return {
21
+ url: joinUrl(call.baseUrl, "/v1/messages"),
22
+ headers: {
23
+ "content-type": "application/json",
24
+ "anthropic-version": "2023-06-01",
25
+ ...authHeaders(call.apiKey, (key) => ({ "x-api-key": key })),
26
+ },
27
+ body: JSON.stringify({
28
+ model: call.model,
29
+ max_tokens: MAX_TOKENS,
30
+ temperature: 0,
31
+ system: call.systemPrompt,
32
+ messages: [{ role: "user", content: call.taskPrompt }],
33
+ }),
34
+ };
35
+ },
36
+ parseResponse(payload: unknown): string | null {
37
+ const content = at(payload, "content");
38
+ if (!Array.isArray(content)) return null;
39
+ // A response can interleave thinking blocks; only text blocks are the answer.
40
+ const parts: string[] = [];
41
+ for (const block of content) {
42
+ if (block === null || typeof block !== "object") continue;
43
+ const typed = block as { type?: unknown; text?: unknown };
44
+ if (typed.type === "text" && typeof typed.text === "string") parts.push(typed.text);
45
+ }
46
+ return parts.length === 0 ? null : parts.join("\n");
47
+ },
48
+ buildModelsRequest(call: ApiModelsCall): ApiHttpRequest {
49
+ return {
50
+ url: joinUrl(call.baseUrl, "/v1/models"),
51
+ headers: {
52
+ "anthropic-version": "2023-06-01",
53
+ ...authHeaders(call.apiKey, (key) => ({ "x-api-key": key })),
54
+ },
55
+ body: "",
56
+ };
57
+ },
58
+ parseModelsResponse(payload: unknown): string[] {
59
+ return stringFieldAt(payload, ["data"], "id");
60
+ },
61
+ };