4c26b984a7
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Routines let users templatize titles and instructions with
`{{name}}` placeholders that get prompted for at run time
> - A user reported that `{{pr_url}}` typed into a routine description
was never detected as a variable, while camelCase placeholders worked
> - Root cause: the routine description is edited via MDXEditor (WYSIWYG
markdown). On save its serializer (`mdast-util-to-markdown`) defensively
escapes intraword underscores, so a user-typed `{{pr_url}}` is stored as
`{{pr\_url}}`. The variable matcher only accepted `[A-Za-z0-9_]` inside
the placeholder, so the backslash broke the match and the variable was
silently dropped
> - This PR widens the matcher to tolerate `\_` and unescapes it back to
`_` on capture, in both extraction and interpolation, so a single name
is detected and resolved regardless of whether the source markdown was
hand-typed or round-tripped through a WYSIWYG editor
> - The benefit is that snake_case placeholders behave the same in the
routine UI and the executor, removing a silent failure mode
## Linked Issues or Issue Description
Refs PAPA-771 — "Fix example for variable_name". Initially scoped as a
docs-copy fix (change the example to camelCase), but investigation
showed the underlying bug was that the parser could not see the variable
when it was authored as `{{pr_url}}` in a WYSIWYG-edited description.
This PR addresses the bug directly so snake_case names work as users
expect. (Supersedes the now-closed PR #8054 — same commit, fresh branch
so the diff is visible.)
## What Changed
- `packages/shared/src/routine-variables.ts`:
- Widened the `ROUTINE_VARIABLE_MATCHER` regex to accept `\_` inside
placeholder names (in addition to `[A-Za-z0-9_]`)
- Added `unescapeRoutineVariableName` and applied it on capture in both
`extractRoutineVariableNames` and `interpolateRoutineTemplate`, so the
looked-up variable name is normalized regardless of the markdown escape
- `packages/shared/src/routine-variables.test.ts`:
- Added tests covering plain snake_case (`{{pr_url}}`), markdown-escaped
(`{{pr\_url}}`), multi-underscore (`{{pr\_url\_v2}}`),
sync-with-template, and interpolation
## Verification
- `pnpm --filter @paperclipai/shared exec vitest run routine-variables`
→ 13 passed (4 new), confirming both plain and escaped underscore
placeholders are detected and interpolated as the same variable name
- Reproduced the root cause out-of-band with `mdast-util-from-markdown`
+ `mdast-util-to-markdown` to confirm the editor's serializer is the
source of the `\_` escape
## Risks
- Low. Change is confined to `packages/shared/src/routine-variables.ts`;
the regex is strictly more permissive in a tightly bounded way (only
`\_` is newly allowed) and the captured name is normalized.
`isValidRoutineVariableName` is unchanged, so stored names are still
strict identifiers.
## Model Used
- Claude (Anthropic), `claude-opus-4-7` (Opus 4.7), via Claude Code
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above — only my own closed #8054 supersedes/duplicates this one.
Open PRs touching `routine-variables.ts` (#7184, #7186, #6993, #7187)
are all for unrelated concerns (server-side PATCH persistence, GH#6525)
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have run tests locally and they pass — `routine-variables` suite
passes (13/13)
- [x] I have added or updated tests where applicable — 4 new tests
covering snake_case + escaped forms
- [ ] If this change affects the UI, I have included before/after
screenshots — no UI change in this PR
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green — pending CI re-run after the
push for the Greptile blank-line nit
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups —
initial review was 5/5; the one P2 (missing blank line) is addressed in
the latest push
- [x] I will address all Greptile and reviewer comments before
requesting merge
Co-authored-by: Paperclip <noreply@paperclip.ing>
113 lines
3.8 KiB
TypeScript
113 lines
3.8 KiB
TypeScript
import type { RoutineVariable } from "./types/routine.js";
|
|
|
|
// Tolerate markdown-escaped underscores (`\_`) inside placeholders. WYSIWYG markdown
|
|
// editors (e.g. MDXEditor) serialize `_` between word chars as `\_` to prevent
|
|
// reparse-as-emphasis, so a user-typed `{{pr_url}}` is stored as `{{pr\_url}}`.
|
|
const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z](?:\\_|[A-Za-z0-9_])*)\s*\}\}/g;
|
|
|
|
function unescapeRoutineVariableName(raw: string): string {
|
|
return raw.replace(/\\_/g, "_");
|
|
}
|
|
|
|
type RoutineTemplateInput = string | null | undefined | Array<string | null | undefined>;
|
|
|
|
/**
|
|
* Built-in variable names that are automatically available in routine templates
|
|
* without needing to be defined in the routine's variables list.
|
|
*/
|
|
export const BUILTIN_ROUTINE_VARIABLE_NAMES = new Set(["date", "timestamp"]);
|
|
|
|
export function isBuiltinRoutineVariable(name: string): boolean {
|
|
return BUILTIN_ROUTINE_VARIABLE_NAMES.has(name);
|
|
}
|
|
|
|
const HUMAN_TIMESTAMP_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
hour12: true,
|
|
timeZone: "UTC",
|
|
timeZoneName: "short",
|
|
});
|
|
|
|
/**
|
|
* Returns current values for all built-in routine variables.
|
|
* `date` expands to the current date in YYYY-MM-DD format (UTC).
|
|
* `timestamp` expands to a human-readable date and time (e.g. "April 28, 2026 at 12:17 PM UTC").
|
|
*/
|
|
export function getBuiltinRoutineVariableValues(): Record<string, string> {
|
|
const now = new Date();
|
|
return {
|
|
date: now.toISOString().slice(0, 10),
|
|
timestamp: HUMAN_TIMESTAMP_FORMATTER.format(now),
|
|
};
|
|
}
|
|
|
|
export function isValidRoutineVariableName(name: string): boolean {
|
|
return /^[A-Za-z][A-Za-z0-9_]*$/.test(name);
|
|
}
|
|
|
|
function normalizeRoutineTemplateInput(input: RoutineTemplateInput): string[] {
|
|
const templates = Array.isArray(input) ? input : [input];
|
|
return templates.filter((template): template is string => typeof template === "string" && template.length > 0);
|
|
}
|
|
|
|
export function extractRoutineVariableNames(template: RoutineTemplateInput): string[] {
|
|
const found = new Set<string>();
|
|
for (const source of normalizeRoutineTemplateInput(template)) {
|
|
for (const match of source.matchAll(ROUTINE_VARIABLE_MATCHER)) {
|
|
const name = match[1] ? unescapeRoutineVariableName(match[1]) : "";
|
|
if (name && !found.has(name)) {
|
|
found.add(name);
|
|
}
|
|
}
|
|
}
|
|
return [...found];
|
|
}
|
|
|
|
function defaultRoutineVariable(name: string): RoutineVariable {
|
|
return {
|
|
name,
|
|
label: null,
|
|
type: "text",
|
|
defaultValue: null,
|
|
required: true,
|
|
options: [],
|
|
};
|
|
}
|
|
|
|
export function syncRoutineVariablesWithTemplate(
|
|
template: RoutineTemplateInput,
|
|
existing: RoutineVariable[] | null | undefined,
|
|
): RoutineVariable[] {
|
|
const names = extractRoutineVariableNames(template).filter((name) => !isBuiltinRoutineVariable(name));
|
|
const existingByName = new Map((existing ?? []).map((variable) => [variable.name, variable]));
|
|
return names.map((name) => existingByName.get(name) ?? defaultRoutineVariable(name));
|
|
}
|
|
|
|
export function stringifyRoutineVariableValue(value: unknown): string {
|
|
if (typeof value === "string") return value;
|
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
if (value == null) return "";
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
export function interpolateRoutineTemplate(
|
|
template: string | null | undefined,
|
|
values: Record<string, unknown> | null | undefined,
|
|
): string | null {
|
|
if (template == null) return null;
|
|
if (!values || Object.keys(values).length === 0) return template;
|
|
return template.replace(ROUTINE_VARIABLE_MATCHER, (match, rawName: string) => {
|
|
const name = unescapeRoutineVariableName(rawName);
|
|
if (!(name in values)) return match;
|
|
return stringifyRoutineVariableValue(values[name]);
|
|
});
|
|
}
|