pi-beer-promptbook 0.1.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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +49 -0
- package/dist/src/core/frontmatter.d.ts +17 -0
- package/dist/src/core/frontmatter.js +35 -0
- package/dist/src/core/profile-parser.d.ts +15 -0
- package/dist/src/core/profile-parser.js +43 -0
- package/dist/src/core/render.d.ts +1 -0
- package/dist/src/core/render.js +21 -0
- package/dist/src/core/resolve.d.ts +20 -0
- package/dist/src/core/resolve.js +31 -0
- package/dist/src/core/types.d.ts +29 -0
- package/dist/src/core/types.js +1 -0
- package/dist/src/frontmatter.d.ts +17 -0
- package/dist/src/frontmatter.js +35 -0
- package/dist/src/loader.d.ts +7 -0
- package/dist/src/loader.js +41 -0
- package/dist/src/profile-parser.d.ts +15 -0
- package/dist/src/profile-parser.js +43 -0
- package/dist/src/registry.d.ts +7 -0
- package/dist/src/registry.js +48 -0
- package/dist/src/render.d.ts +1 -0
- package/dist/src/render.js +21 -0
- package/dist/src/resolve.d.ts +20 -0
- package/dist/src/resolve.js +31 -0
- package/dist/src/task.d.ts +29 -0
- package/dist/src/task.js +115 -0
- package/dist/src/types.d.ts +29 -0
- package/dist/src/types.js +1 -0
- package/index.ts +72 -0
- package/package.json +63 -0
- package/profiles/classify-memory.md +44 -0
- package/profiles/code-review.md +38 -0
- package/profiles/debug.md +37 -0
- package/profiles/handoff-summary.md +25 -0
- package/profiles/library-research.md +45 -0
- package/profiles/multilingual-rules.md +22 -0
- package/profiles/security-review.md +29 -0
- package/profiles/test-write.md +39 -0
- package/profiles/ui-design.md +44 -0
- package/src/core/frontmatter.ts +51 -0
- package/src/core/profile-parser.ts +45 -0
- package/src/core/render.ts +24 -0
- package/src/core/resolve.ts +46 -0
- package/src/core/types.ts +31 -0
- package/src/loader.ts +45 -0
- package/src/registry.ts +54 -0
- package/src/task.ts +137 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- Task-routed prompt profiles: a profile is a plain `.md` (frontmatter
|
|
8
|
+
`name`/`version`/`task`; the body IS the prompt); `resolveTask("task")`
|
|
9
|
+
returns it. Dumb registry — no roles, no decisions, no LLM calls.
|
|
10
|
+
- Pi extension via the official `before_agent_start` hook: appends the
|
|
11
|
+
resolved rules block every turn; never doubles, never breaks the prompt.
|
|
12
|
+
- 9 bundled disciplines: multilingual-rules (default), classify-memory,
|
|
13
|
+
code-review, security-review, handoff-summary, ui-design,
|
|
14
|
+
library-research, debug, test-write.
|
|
15
|
+
- Fail-safe tiers: task-scoped pin → `PROMPTBOOK_TASK_MAP` env → user
|
|
16
|
+
dir → builtin → caller fallback. Never throws; typo'd tasks get a
|
|
17
|
+
near-match suggestion.
|
|
18
|
+
- User override: drop a `.md` in `~/.pi/agent/pi-beer-promptbook-profiles/`.
|
|
19
|
+
- CI: strict typecheck, 80-assert smoke (real compile + real import),
|
|
20
|
+
28-case routing matrix.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DanHuy-04
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# pi-beer-promptbook
|
|
2
|
+
|
|
3
|
+
Prompt profiles as markdown, routed by task. A profile is a plain `.md` file whose frontmatter declares its `task`; any package asks `resolveTask("the-task")` and gets that prompt. Zero runtime dependencies, zero LLM calls, zero registration.
|
|
4
|
+
|
|
5
|
+
Formerly `pi-promptbook`. Old URLs redirect; profiles dropped in the pre-rename user dirs (`~/.pi/agent/pi-promptbook-profiles/`, `pi-beer-prompt-profiles/`) keep resolving — writes go to `pi-beer-promptbook-profiles/`.
|
|
6
|
+
|
|
7
|
+
## The idea
|
|
8
|
+
|
|
9
|
+
- **Profile** = one `.md` file. Frontmatter is the metadata (`name`, `task`, …); the body *is* the prompt.
|
|
10
|
+
- **Task** = the routing key — kebab-case, e.g. `summarize`, `audit`, `rules`.
|
|
11
|
+
- **Registry** = this package. It answers exactly one question — *which profile serves task X?* — and nothing else.
|
|
12
|
+
|
|
13
|
+
Swap the prompt behind a task by dropping a different file in the user dir. No code changes, no registration, no config. The old file simply stops winning.
|
|
14
|
+
|
|
15
|
+
## Quick start — as a Pi extension
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pi install git:github.com/DanHuy-04/pi-beer-promptbook
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That's it. Every turn, the resolved rules block is appended to the system prompt: the agent parses messy multilingual messages itself (goal → knowns → constraints, code/paths/errors kept verbatim) and answers in the user's language. The block never doubles across turns, and a broken profile never touches the prompt — set `PROMPTBOOK_OFF=1` to disable.
|
|
22
|
+
|
|
23
|
+
Default profile: `multilingual-rules`. Point another one at your model with `PROMPTBOOK_PROFILE`, or per-model with `PROMPTBOOK_PROFILE_MAP` (see [Configuration](#configuration)).
|
|
24
|
+
|
|
25
|
+
## Quick start — as a library
|
|
26
|
+
|
|
27
|
+
For package authors who need prompts without hardcoding them:
|
|
28
|
+
|
|
29
|
+
```jsonc
|
|
30
|
+
// package.json
|
|
31
|
+
"dependencies": { "pi-beer-promptbook": "github:DanHuy-04/pi-beer-promptbook" }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { resolveTask, getBuiltin } from "pi-beer-promptbook";
|
|
36
|
+
|
|
37
|
+
const { profile, warnings } = resolveTask({
|
|
38
|
+
task: "summarize", // your package's task key
|
|
39
|
+
spec: configuredPin || undefined, // explicit pin wins when set
|
|
40
|
+
fallback: getBuiltin("multilingual-rules"),
|
|
41
|
+
});
|
|
42
|
+
const prompt = profile?.build(); // → string
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Your users override the prompt without touching your code:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
~/.pi/agent/pi-beer-promptbook-profiles/my-summary.md ← task: summarize
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The file is picked up on the next `resolveTask` call. Task vocabularies belong to consumers — this package knows no task names, which is what keeps drop-in distribution possible.
|
|
52
|
+
|
|
53
|
+
## Routing rules
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
resolveTask(task)
|
|
57
|
+
|
|
|
58
|
+
v
|
|
59
|
+
1 explicit pin (spec) -- hit --> profile
|
|
60
|
+
| miss
|
|
61
|
+
v
|
|
62
|
+
2 PROMPTBOOK_TASK_MAP env -- hit --> profile
|
|
63
|
+
| miss
|
|
64
|
+
v
|
|
65
|
+
3 user profile declaring it -- hit --> profile
|
|
66
|
+
| miss
|
|
67
|
+
v
|
|
68
|
+
4 builtin declaring it -- hit --> profile
|
|
69
|
+
| miss
|
|
70
|
+
v
|
|
71
|
+
5 caller's fallback ---------> profile
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- **The pin is task-scoped.** A pinned profile is used only if it declares
|
|
75
|
+
the task being asked; otherwise resolution warns and continues down the
|
|
76
|
+
chain — a pin can prefer, never redirect across tasks.
|
|
77
|
+
- **Never throws.** Every tier falls through; an unserved task returns warnings instead:
|
|
78
|
+
`task "compact-clasify" has no profile — did you mean "compact-classify"? — using fallback`
|
|
79
|
+
- **Ambiguity is deterministic.** Several user profiles declaring the same task → first by path, with a warning.
|
|
80
|
+
- **Task form is enforced at parse time** (`^[a-z][a-z0-9-]*$`) — a typo like `Compact Classify` fails when the file is saved, not silently at routing time.
|
|
81
|
+
|
|
82
|
+
## Profile format
|
|
83
|
+
|
|
84
|
+
```md
|
|
85
|
+
---
|
|
86
|
+
name: my-rules
|
|
87
|
+
version: 1.0.0
|
|
88
|
+
task: rules
|
|
89
|
+
description: What behavior this profile shapes.
|
|
90
|
+
placeholders: []
|
|
91
|
+
---
|
|
92
|
+
<my-rules>
|
|
93
|
+
Answer in {{language}}.
|
|
94
|
+
…your rules…
|
|
95
|
+
</my-rules>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- `name`, `version`, `task` are required; body must be non-empty.
|
|
99
|
+
- The body after the frontmatter IS the prompt block.
|
|
100
|
+
- `{{var}}` and `{{#if x}}…{{/if}}` render at build time (empty/missing → empty string).
|
|
101
|
+
- XML tags are convention, not requirement.
|
|
102
|
+
|
|
103
|
+
Bundled profiles:
|
|
104
|
+
|
|
105
|
+
| Profile | Task | Purpose |
|
|
106
|
+
|---|---|---|
|
|
107
|
+
| `multilingual-rules` | `rules` | Multilingual message parsing, verbatim facts, answer in the user's language |
|
|
108
|
+
| `classify-memory` | `compact-classify` | Language-agnostic fact classifier for compaction pipelines |
|
|
109
|
+
| `code-review` | `code-review` | Findings ordered correctness → security → maintainability, each with evidence and fix |
|
|
110
|
+
| `security-review` | `security-review` | Evidence-backed vulnerability hunting — location, class, attack path, trigger |
|
|
111
|
+
| `handoff-summary` | `summarize` | Compressed handoff: state, decisions, risks, next steps — nothing else |
|
|
112
|
+
| `ui-design` | `ui-design` | Token-first design system, all UI states, accessibility, AI-slop avoidance |
|
|
113
|
+
| `library-research` | `library-research` | Source-verified library answers — verbatim signatures, cross-referenced evidence |
|
|
114
|
+
| `debug` | `debug` | Root-cause discipline — reproduce, quote evidence, fix the mechanism, re-run |
|
|
115
|
+
| `test-write` | `test-write` | Tests verify the contract — failing test before fix, never edit a test to pass |
|
|
116
|
+
|
|
117
|
+
## Configuration
|
|
118
|
+
|
|
119
|
+
| Env | Mode | Effect |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| `PROMPTBOOK_PROFILE=<name or path>` | extension | profile for every model |
|
|
122
|
+
| `PROMPTBOOK_PROFILE_MAP='{"sonnet":"./terse.md","gpt-5":"multilingual-rules"}'` | extension | per-model — a key matches when the model id contains it; longest key wins |
|
|
123
|
+
| `PROMPTBOOK_TASK_MAP='{"summarize":"my-profile"}'` | library | per-task pin |
|
|
124
|
+
| `PROMPTBOOK_OFF=1` | extension | hook not registered |
|
|
125
|
+
|
|
126
|
+
Legacy `BEER_PROMPT_*` names are still honored. Use **forward slashes** in JSON paths (`C:/rules/my.md`) — single backslashes are invalid JSON escapes; bad values fall back safely.
|
|
127
|
+
|
|
128
|
+
## Library surface
|
|
129
|
+
|
|
130
|
+
| Export | Purpose |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `resolveTask({ task, spec?, fallback? })` | Route by task key → `{ profile, source, warnings }` |
|
|
133
|
+
| `loadProfile(spec?)` | Resolve by builtin name or `.md` path → `{ profile, source, warning? }` |
|
|
134
|
+
| `listUserProfiles()` / `userProfilesDir()` | User-dir scan (current + legacy dirs) / where writes go |
|
|
135
|
+
| `listBuiltins()` / `getBuiltin(name)` | Bundled profile registry |
|
|
136
|
+
| `resolveForModel({ mapRaw, profileRaw, modelId, fallback })` | Per-model resolution (extension-mode internals) |
|
|
137
|
+
| `parseProfileMarkdown(md, label?)` | Compile markdown into a `PromptProfile` |
|
|
138
|
+
| `render(template, input)` | `{{var}}` + `{{#if}}` rendering |
|
|
139
|
+
| `DEFAULT_PROFILE_NAME` | `"multilingual-rules"` |
|
|
140
|
+
| types: `ProfileMeta`, `PromptProfile`, `LoadResult`, `TaskResolveInput/Result`, `UserProfileEntry`, `ResolveInput/Result` | |
|
|
141
|
+
|
|
142
|
+
## Architecture
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
pi-beer-promptbook/
|
|
146
|
+
├── index.ts # shell: extension factory (before_agent_start) + public exports
|
|
147
|
+
├── src/
|
|
148
|
+
│ ├── core/ # PURE — no SDK imports, no I/O
|
|
149
|
+
│ │ ├── types.ts # contract
|
|
150
|
+
│ │ ├── frontmatter.ts # mini YAML parser
|
|
151
|
+
│ │ ├── render.ts # {{var}}/{{#if}} renderer
|
|
152
|
+
│ │ ├── profile-parser.ts # markdown → PromptProfile (task form enforced)
|
|
153
|
+
│ │ └── resolve.ts # per-model tier resolution (delegates disk to the loader seam)
|
|
154
|
+
│ ├── registry.ts # disk seam: bundled profiles dir
|
|
155
|
+
│ ├── loader.ts # disk seam: one .md by name/path
|
|
156
|
+
│ └── task.ts # disk seam: user dirs + task routing
|
|
157
|
+
├── profiles/ # data: the bundled profiles
|
|
158
|
+
└── scripts/ # smoke.mjs (compile+import, no mocks) · matrix.mjs (28-case routing)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Pure core / imperative shell: decisions live in `src/core/` and are testable with plain Node; every filesystem read sits behind the three `src/` seams (`registry`, `loader`, `task`) — `core/resolve.ts` reaches disk only through the loader seam.
|
|
162
|
+
|
|
163
|
+
## Development
|
|
164
|
+
|
|
165
|
+
```sh
|
|
166
|
+
npm install
|
|
167
|
+
npm run check # typecheck (strict) + smoke: real compile, real import
|
|
168
|
+
npm run build # tsc emit → dist/ (what consumers run)
|
|
169
|
+
node scripts/matrix.mjs # 28-case routing matrix (same gates CI runs)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## License
|
|
173
|
+
|
|
174
|
+
MIT — see [LICENSE](LICENSE).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-beer-promptbook — task-routed prompt profiles for Pi.
|
|
3
|
+
*
|
|
4
|
+
* As a Pi extension (default export): appends a rule block from a markdown
|
|
5
|
+
* profile to the system prompt every turn, via the official
|
|
6
|
+
* `before_agent_start` hook. The agent itself then parses messy
|
|
7
|
+
* multilingual user messages and answers in the user's language —
|
|
8
|
+
* ZERO extra LLM calls, zero latency, no second model that could distort
|
|
9
|
+
* the user's words.
|
|
10
|
+
*
|
|
11
|
+
* As a library (named exports): profiles are plain markdown; the loader
|
|
12
|
+
* resolves builtin names, file paths, or env specs with fail-safe, and
|
|
13
|
+
* resolveTask routes by the frontmatter `task` key (the dumb registry:
|
|
14
|
+
* task -> prompt, no roles, no decisions).
|
|
15
|
+
*/
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
declare const _default: (pi: ExtensionAPI) => void;
|
|
18
|
+
export default _default;
|
|
19
|
+
export type { ProfileMeta, PromptProfile, LoadResult } from "./src/core/types.js";
|
|
20
|
+
export { parseProfileMarkdown } from "./src/core/profile-parser.js";
|
|
21
|
+
export { render } from "./src/core/render.js";
|
|
22
|
+
export { DEFAULT_PROFILE_NAME, getBuiltin, listBuiltins } from "./src/registry.js";
|
|
23
|
+
export { loadProfile } from "./src/loader.js";
|
|
24
|
+
export { resolveTask, listUserProfiles, userProfilesDir } from "./src/task.js";
|
|
25
|
+
export type { TaskResolveInput, TaskResolveResult, UserProfileEntry } from "./src/task.js";
|
|
26
|
+
export { resolveForModel } from "./src/core/resolve.js";
|
|
27
|
+
export type { ResolveInput, ResolveResult } from "./src/core/resolve.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { DEFAULT_PROFILE_NAME, getBuiltin } from "./src/registry.js";
|
|
2
|
+
import { resolveForModel } from "./src/core/resolve.js";
|
|
3
|
+
const modelIdOf = (ctx) => {
|
|
4
|
+
const model = ctx?.model;
|
|
5
|
+
const id = typeof model?.id === "string" ? model.id : typeof model?.name === "string" ? model.name : "";
|
|
6
|
+
return id.toLowerCase();
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Resolve the active profile for the current model. Priority:
|
|
10
|
+
* 1. BEER_PROMPT_PROFILE_MAP — JSON object { "<model-id substring>": "<spec>" };
|
|
11
|
+
* a key matches when the model id contains it; the longest matching key wins.
|
|
12
|
+
* 2. BEER_PROMPT_PROFILE — every model.
|
|
13
|
+
* 3. Built-in default.
|
|
14
|
+
* A failed tier falls through to the next — never breaks the system prompt.
|
|
15
|
+
*/
|
|
16
|
+
const resolveProfile = (ctx) => resolveForModel({
|
|
17
|
+
mapRaw: process.env.PROMPTBOOK_PROFILE_MAP ?? process.env.BEER_PROMPT_PROFILE_MAP,
|
|
18
|
+
profileRaw: process.env.PROMPTBOOK_PROFILE ?? process.env.BEER_PROMPT_PROFILE,
|
|
19
|
+
modelId: modelIdOf(ctx),
|
|
20
|
+
fallback: getBuiltin(DEFAULT_PROFILE_NAME),
|
|
21
|
+
}).profile;
|
|
22
|
+
export default (pi) => {
|
|
23
|
+
if (process.env.PROMPTBOOK_OFF || process.env.BEER_PROMPT_OFF)
|
|
24
|
+
return;
|
|
25
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
26
|
+
const base = event.systemPrompt;
|
|
27
|
+
if (!base)
|
|
28
|
+
return;
|
|
29
|
+
const rules = resolveProfile(ctx);
|
|
30
|
+
if (!rules)
|
|
31
|
+
return;
|
|
32
|
+
let block;
|
|
33
|
+
try {
|
|
34
|
+
block = rules.build().trim();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return; // a broken profile must never damage the system prompt
|
|
38
|
+
}
|
|
39
|
+
if (!block || base.includes(block))
|
|
40
|
+
return; // never append twice
|
|
41
|
+
return { systemPrompt: `${base}\n\n${block}` };
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
export { parseProfileMarkdown } from "./src/core/profile-parser.js";
|
|
45
|
+
export { render } from "./src/core/render.js";
|
|
46
|
+
export { DEFAULT_PROFILE_NAME, getBuiltin, listBuiltins } from "./src/registry.js";
|
|
47
|
+
export { loadProfile } from "./src/loader.js";
|
|
48
|
+
export { resolveTask, listUserProfiles, userProfilesDir } from "./src/task.js";
|
|
49
|
+
export { resolveForModel } from "./src/core/resolve.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal YAML frontmatter parser — scalars and string lists only.
|
|
3
|
+
* Zero dependencies by design: profiles are data, and the accepted
|
|
4
|
+
* frontmatter shape is deliberately small (name/version/task/description/
|
|
5
|
+
* placeholders). Profiles needing more belong to the caller, not the format.
|
|
6
|
+
*/
|
|
7
|
+
export type FrontmatterValue = string | string[];
|
|
8
|
+
export interface Frontmatter {
|
|
9
|
+
[key: string]: FrontmatterValue;
|
|
10
|
+
}
|
|
11
|
+
export interface ParsedMarkdown {
|
|
12
|
+
fm: Frontmatter;
|
|
13
|
+
/** Body after the closing `---`. */
|
|
14
|
+
body: string;
|
|
15
|
+
}
|
|
16
|
+
/** Parse a `---` frontmatter block; throws with a clear message when absent or malformed. */
|
|
17
|
+
export declare const parseFrontmatter: (md: string) => ParsedMarkdown;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
2
|
+
const LIST_ITEM_RE = /^\s+-\s+(.*)$/;
|
|
3
|
+
const KEY_VALUE_RE = /^([A-Za-z0-9_-]+):\s*(.*)$/;
|
|
4
|
+
const stripQuotes = (value) => value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
|
|
5
|
+
/** Parse a `---` frontmatter block; throws with a clear message when absent or malformed. */
|
|
6
|
+
export const parseFrontmatter = (md) => {
|
|
7
|
+
const match = FRONTMATTER_RE.exec(md);
|
|
8
|
+
if (!match)
|
|
9
|
+
throw new Error("profile markdown must start with a --- frontmatter block");
|
|
10
|
+
const fm = {};
|
|
11
|
+
let currentList = null;
|
|
12
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
13
|
+
const trimmed = line.trim();
|
|
14
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
15
|
+
continue;
|
|
16
|
+
const listItem = LIST_ITEM_RE.exec(line);
|
|
17
|
+
if (listItem && currentList) {
|
|
18
|
+
currentList.push(stripQuotes(listItem[1].trim()));
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const kv = KEY_VALUE_RE.exec(line);
|
|
22
|
+
if (!kv)
|
|
23
|
+
throw new Error(`unparseable frontmatter line: "${line}"`);
|
|
24
|
+
const value = kv[2].trim();
|
|
25
|
+
if (value === "") {
|
|
26
|
+
currentList = [];
|
|
27
|
+
fm[kv[1]] = currentList;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
currentList = null;
|
|
31
|
+
fm[kv[1]] = stripQuotes(value);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { fm, body: match[2] ?? "" };
|
|
35
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PromptProfile } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Compile a rules profile (frontmatter + rule-block body) into a
|
|
4
|
+
* PromptProfile. Layout — the body after the closing `---` IS the rule
|
|
5
|
+
* block, appended verbatim (after {{var}} rendering) to the host agent's
|
|
6
|
+
* system prompt:
|
|
7
|
+
*
|
|
8
|
+
* ---
|
|
9
|
+
* name / version / task / description / placeholders
|
|
10
|
+
* ---
|
|
11
|
+
* <user-messages>
|
|
12
|
+
* …rules the agent itself follows…
|
|
13
|
+
* </user-messages>
|
|
14
|
+
*/
|
|
15
|
+
export declare const parseProfileMarkdown: (md: string, sourceLabel?: string) => PromptProfile;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { parseFrontmatter } from "./frontmatter.js";
|
|
2
|
+
import { render } from "./render.js";
|
|
3
|
+
/**
|
|
4
|
+
* Compile a rules profile (frontmatter + rule-block body) into a
|
|
5
|
+
* PromptProfile. Layout — the body after the closing `---` IS the rule
|
|
6
|
+
* block, appended verbatim (after {{var}} rendering) to the host agent's
|
|
7
|
+
* system prompt:
|
|
8
|
+
*
|
|
9
|
+
* ---
|
|
10
|
+
* name / version / task / description / placeholders
|
|
11
|
+
* ---
|
|
12
|
+
* <user-messages>
|
|
13
|
+
* …rules the agent itself follows…
|
|
14
|
+
* </user-messages>
|
|
15
|
+
*/
|
|
16
|
+
export const parseProfileMarkdown = (md, sourceLabel = "profile") => {
|
|
17
|
+
const { fm, body } = parseFrontmatter(md);
|
|
18
|
+
const name = typeof fm.name === "string" ? fm.name : "";
|
|
19
|
+
const version = typeof fm.version === "string" ? fm.version : "";
|
|
20
|
+
const task = typeof fm.task === "string" ? fm.task : "";
|
|
21
|
+
if (!name || !version || !task) {
|
|
22
|
+
throw new Error(`${sourceLabel}: frontmatter requires name, version, task`);
|
|
23
|
+
}
|
|
24
|
+
// Task is a routing key, so its FORM is part of the contract: kebab-case
|
|
25
|
+
// only. A typo ("Compact Classify", "compact_clasify") must fail HERE —
|
|
26
|
+
// at parse/save time — instead of silently never matching at resolve time.
|
|
27
|
+
if (!/^[a-z][a-z0-9-]*$/.test(task)) {
|
|
28
|
+
throw new Error(`${sourceLabel}: task "${task}" must be kebab-case (lowercase letters, digits, dashes)`);
|
|
29
|
+
}
|
|
30
|
+
const ruleBlock = body.trim();
|
|
31
|
+
if (!ruleBlock) {
|
|
32
|
+
throw new Error(`${sourceLabel}: rule block body must be non-empty`);
|
|
33
|
+
}
|
|
34
|
+
const build = (input = {}) => render(ruleBlock, input);
|
|
35
|
+
return {
|
|
36
|
+
name,
|
|
37
|
+
version,
|
|
38
|
+
task,
|
|
39
|
+
description: typeof fm.description === "string" ? fm.description : undefined,
|
|
40
|
+
placeholders: Array.isArray(fm.placeholders) ? fm.placeholders : [],
|
|
41
|
+
build,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const render: (template: string, input: Record<string, unknown>) => string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template renderer: `{{variable}}` substitution and `{{#if variable}}…{{/if}}`
|
|
3
|
+
* blocks (truthy = non-empty string after trim, or non-null non-false value).
|
|
4
|
+
* No nesting, no else — deliberately minimal; profiles are data, not programs.
|
|
5
|
+
*/
|
|
6
|
+
const IF_BLOCK_RE = /\{\{#if\s+([A-Za-z0-9_.-]+)\}\}([\s\S]*?)\{\{\/if\}\}/;
|
|
7
|
+
const VAR_RE = /\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g;
|
|
8
|
+
const isTruthy = (value) => typeof value === "string" ? value.trim().length > 0 : value !== null && value !== undefined && value !== false;
|
|
9
|
+
export const render = (template, input) => {
|
|
10
|
+
let out = template;
|
|
11
|
+
let match = IF_BLOCK_RE.exec(out);
|
|
12
|
+
while (match !== null) {
|
|
13
|
+
const replacement = isTruthy(input[match[1]]) ? match[2] : "";
|
|
14
|
+
out = out.slice(0, match.index) + replacement + out.slice(match.index + match[0].length);
|
|
15
|
+
match = IF_BLOCK_RE.exec(out);
|
|
16
|
+
}
|
|
17
|
+
return out.replace(VAR_RE, (_full, key) => {
|
|
18
|
+
const value = input[key];
|
|
19
|
+
return value === null || value === undefined ? "" : String(value);
|
|
20
|
+
});
|
|
21
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PromptProfile } from "./types.js";
|
|
2
|
+
export interface ResolveInput {
|
|
3
|
+
/** JSON object `{ "<model-id substring>": "<spec>" }` — a key matches when
|
|
4
|
+
* the model id contains it; the longest matching key wins. */
|
|
5
|
+
mapRaw?: string | undefined;
|
|
6
|
+
/** Single spec applied to every model. */
|
|
7
|
+
profileRaw?: string | undefined;
|
|
8
|
+
/** Model id (case-insensitive match). "" simply never matches the MAP tier. */
|
|
9
|
+
modelId: string;
|
|
10
|
+
/** Last-resort profile — usually a bundled builtin. May be null. */
|
|
11
|
+
fallback: PromptProfile | null;
|
|
12
|
+
}
|
|
13
|
+
export interface ResolveResult {
|
|
14
|
+
profile: PromptProfile | null;
|
|
15
|
+
tier: "map" | "profile" | "fallback" | "none";
|
|
16
|
+
}
|
|
17
|
+
/** Per-model profile resolution shared by every Beer package: MAP tier,
|
|
18
|
+
* then the single-profile tier, then the caller's fallback. A failed tier
|
|
19
|
+
* falls through to the next — this never throws and never breaks a caller. */
|
|
20
|
+
export declare const resolveForModel: (input: ResolveInput) => ResolveResult;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { loadProfile } from "../loader.js";
|
|
2
|
+
/** Per-model profile resolution shared by every Beer package: MAP tier,
|
|
3
|
+
* then the single-profile tier, then the caller's fallback. A failed tier
|
|
4
|
+
* falls through to the next — this never throws and never breaks a caller. */
|
|
5
|
+
export const resolveForModel = (input) => {
|
|
6
|
+
if (input.mapRaw) {
|
|
7
|
+
try {
|
|
8
|
+
const map = JSON.parse(input.mapRaw);
|
|
9
|
+
const id = input.modelId.toLowerCase();
|
|
10
|
+
const keys = Object.keys(map)
|
|
11
|
+
.filter((key) => id.includes(key.toLowerCase()))
|
|
12
|
+
.sort((a, b) => b.length - a.length);
|
|
13
|
+
if (keys.length > 0) {
|
|
14
|
+
const byModel = loadProfile(map[keys[0]]);
|
|
15
|
+
if (byModel.profile)
|
|
16
|
+
return { profile: byModel.profile, tier: "map" };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// malformed MAP JSON — skip the tier, fall through
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (input.profileRaw) {
|
|
24
|
+
const { profile } = loadProfile(input.profileRaw);
|
|
25
|
+
if (profile)
|
|
26
|
+
return { profile, tier: "profile" };
|
|
27
|
+
}
|
|
28
|
+
if (input.fallback)
|
|
29
|
+
return { profile: input.fallback, tier: "fallback" };
|
|
30
|
+
return { profile: null, tier: "none" };
|
|
31
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Metadata parsed from a profile's YAML frontmatter. */
|
|
2
|
+
export interface ProfileMeta {
|
|
3
|
+
/** Stable identifier used for routing: "multilingual-rules", … */
|
|
4
|
+
name: string;
|
|
5
|
+
/** Profile version traced alongside every result it produces. */
|
|
6
|
+
version: string;
|
|
7
|
+
/** Routing key — kebab-case, validated at parse time. */
|
|
8
|
+
task: string;
|
|
9
|
+
/** Routing hint: what behavior this profile shapes. */
|
|
10
|
+
description?: string;
|
|
11
|
+
/** Variable names the rule block references (all optional at runtime). */
|
|
12
|
+
placeholders: string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A rules profile: markdown compiled into a builder. `build` returns the
|
|
16
|
+
* rule block to append to the host agent's system prompt.
|
|
17
|
+
*/
|
|
18
|
+
export interface PromptProfile extends ProfileMeta {
|
|
19
|
+
build(input?: Record<string, unknown>): string;
|
|
20
|
+
}
|
|
21
|
+
/** Result of resolving a profile by spec. */
|
|
22
|
+
export interface LoadResult {
|
|
23
|
+
/** null when nothing was specified or the spec failed — caller falls back. */
|
|
24
|
+
profile: PromptProfile | null;
|
|
25
|
+
/** "builtin:<name>" | "file:<resolved path>" | "none" */
|
|
26
|
+
source: string;
|
|
27
|
+
/** Set when a spec failed to resolve. */
|
|
28
|
+
warning?: string;
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal YAML frontmatter parser — scalars and string lists only.
|
|
3
|
+
* Zero dependencies by design: profiles are data, and the accepted
|
|
4
|
+
* frontmatter shape is deliberately small (name/version/task/description/
|
|
5
|
+
* placeholders). Profiles needing more belong to the caller, not the format.
|
|
6
|
+
*/
|
|
7
|
+
export type FrontmatterValue = string | string[];
|
|
8
|
+
export interface Frontmatter {
|
|
9
|
+
[key: string]: FrontmatterValue;
|
|
10
|
+
}
|
|
11
|
+
export interface ParsedMarkdown {
|
|
12
|
+
fm: Frontmatter;
|
|
13
|
+
/** Body after the closing `---`. */
|
|
14
|
+
body: string;
|
|
15
|
+
}
|
|
16
|
+
/** Parse a `---` frontmatter block; throws with a clear message when absent or malformed. */
|
|
17
|
+
export declare const parseFrontmatter: (md: string) => ParsedMarkdown;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
2
|
+
const LIST_ITEM_RE = /^\s+-\s+(.*)$/;
|
|
3
|
+
const KEY_VALUE_RE = /^([A-Za-z0-9_-]+):\s*(.*)$/;
|
|
4
|
+
const stripQuotes = (value) => value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
|
|
5
|
+
/** Parse a `---` frontmatter block; throws with a clear message when absent or malformed. */
|
|
6
|
+
export const parseFrontmatter = (md) => {
|
|
7
|
+
const match = FRONTMATTER_RE.exec(md);
|
|
8
|
+
if (!match)
|
|
9
|
+
throw new Error("profile markdown must start with a --- frontmatter block");
|
|
10
|
+
const fm = {};
|
|
11
|
+
let currentList = null;
|
|
12
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
13
|
+
const trimmed = line.trim();
|
|
14
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
15
|
+
continue;
|
|
16
|
+
const listItem = LIST_ITEM_RE.exec(line);
|
|
17
|
+
if (listItem && currentList) {
|
|
18
|
+
currentList.push(stripQuotes(listItem[1].trim()));
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const kv = KEY_VALUE_RE.exec(line);
|
|
22
|
+
if (!kv)
|
|
23
|
+
throw new Error(`unparseable frontmatter line: "${line}"`);
|
|
24
|
+
const value = kv[2].trim();
|
|
25
|
+
if (value === "") {
|
|
26
|
+
currentList = [];
|
|
27
|
+
fm[kv[1]] = currentList;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
currentList = null;
|
|
31
|
+
fm[kv[1]] = stripQuotes(value);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { fm, body: match[2] ?? "" };
|
|
35
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { LoadResult } from "./core/types.js";
|
|
2
|
+
/** Resolve ONE spec — a built-in profile name or a path to a `.md` file.
|
|
3
|
+
* Resolution NEVER falls back silently inside this function — a failed spec
|
|
4
|
+
* returns `profile: null` plus a warning, and the caller chooses its own
|
|
5
|
+
* fallback (typically `getBuiltin(DEFAULT_PROFILE_NAME)`). A bad spec can
|
|
6
|
+
* never break the caller. */
|
|
7
|
+
export declare const loadProfile: (spec?: string) => LoadResult;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { getBuiltin } from "./registry.js";
|
|
4
|
+
import { parseProfileMarkdown } from "./core/profile-parser.js";
|
|
5
|
+
const isPathLike = (value) => value.startsWith(".") ||
|
|
6
|
+
value.startsWith("/") ||
|
|
7
|
+
value.startsWith("\\") ||
|
|
8
|
+
/^[a-zA-Z]:[\\/]/.test(value) ||
|
|
9
|
+
value.endsWith(".md");
|
|
10
|
+
/** Resolve ONE spec — a built-in profile name or a path to a `.md` file.
|
|
11
|
+
* Resolution NEVER falls back silently inside this function — a failed spec
|
|
12
|
+
* returns `profile: null` plus a warning, and the caller chooses its own
|
|
13
|
+
* fallback (typically `getBuiltin(DEFAULT_PROFILE_NAME)`). A bad spec can
|
|
14
|
+
* never break the caller. */
|
|
15
|
+
export const loadProfile = (spec) => {
|
|
16
|
+
const raw = spec?.trim() || "";
|
|
17
|
+
if (!raw)
|
|
18
|
+
return { profile: null, source: "none" };
|
|
19
|
+
const builtin = getBuiltin(raw);
|
|
20
|
+
if (builtin)
|
|
21
|
+
return { profile: builtin, source: `builtin:${raw}` };
|
|
22
|
+
if (isPathLike(raw)) {
|
|
23
|
+
const path = resolve(raw);
|
|
24
|
+
try {
|
|
25
|
+
const profile = parseProfileMarkdown(readFileSync(path, "utf8"), raw);
|
|
26
|
+
return { profile, source: `file:${path}` };
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
return {
|
|
30
|
+
profile: null,
|
|
31
|
+
source: "none",
|
|
32
|
+
warning: `profile "${raw}" failed to load (${error.message})`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
profile: null,
|
|
38
|
+
source: "none",
|
|
39
|
+
warning: `profile "${raw}" not found (not a built-in name, not a path)`,
|
|
40
|
+
};
|
|
41
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PromptProfile } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Compile a rules profile (frontmatter + rule-block body) into a
|
|
4
|
+
* PromptProfile. Layout — the body after the closing `---` IS the rule
|
|
5
|
+
* block, appended verbatim (after {{var}} rendering) to the host agent's
|
|
6
|
+
* system prompt:
|
|
7
|
+
*
|
|
8
|
+
* ---
|
|
9
|
+
* name / version / task / description / placeholders
|
|
10
|
+
* ---
|
|
11
|
+
* <user-messages>
|
|
12
|
+
* …rules the agent itself follows…
|
|
13
|
+
* </user-messages>
|
|
14
|
+
*/
|
|
15
|
+
export declare const parseProfileMarkdown: (md: string, sourceLabel?: string) => PromptProfile;
|