@moikapy/origen 0.3.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/README.md +167 -0
- package/bun.lock +561 -0
- package/package.json +27 -0
- package/src/adapter.ts +364 -0
- package/src/agent.ts +219 -0
- package/src/index.ts +58 -0
- package/src/models.ts +143 -0
- package/src/soul.ts +508 -0
- package/src/types.ts +59 -0
- package/test/origen.test.ts +171 -0
- package/tsconfig.json +17 -0
package/src/models.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Origen model configuration.
|
|
3
|
+
*
|
|
4
|
+
* Delegates to pi-ai's model registry for known providers (OpenRouter, Anthropic, Google, etc.)
|
|
5
|
+
* Plus custom entries for Ollama and free-tier aliases.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getModel } from "@mariozechner/pi-ai";
|
|
9
|
+
import type { Model, Api } from "@mariozechner/pi-ai";
|
|
10
|
+
export type { Model as ProviderModel, Api } from "@mariozechner/pi-ai";
|
|
11
|
+
|
|
12
|
+
// ── Model registry ────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface ModelConfig {
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
free: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** UI-facing model config — safe to send to the client. Strips internal fields. */
|
|
21
|
+
export type UIModelConfig = ModelConfig;
|
|
22
|
+
|
|
23
|
+
/** Get models as a simple UI map (name, description, free). No internal fields. */
|
|
24
|
+
export function getModelsForUI(): Record<string, UIModelConfig> {
|
|
25
|
+
const uiModels: Record<string, UIModelConfig> = {};
|
|
26
|
+
for (const [id, config] of Object.entries(MODELS)) {
|
|
27
|
+
uiModels[id] = { name: config.name, description: config.description, free: config.free };
|
|
28
|
+
}
|
|
29
|
+
return uiModels;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Build MODELS map from pi-ai registry + custom entries
|
|
33
|
+
function buildModels(): Record<string, ModelConfig> {
|
|
34
|
+
const models: Record<string, ModelConfig> = {};
|
|
35
|
+
|
|
36
|
+
// ── OpenRouter (free tier) ───────────────────────────
|
|
37
|
+
models["openrouter/free"] = {
|
|
38
|
+
name: "Free (Auto)",
|
|
39
|
+
description: "Free — auto-selects best free model for your request",
|
|
40
|
+
free: true,
|
|
41
|
+
};
|
|
42
|
+
models["google/gemma-4-31b-it:free"] = {
|
|
43
|
+
name: "Gemma 4 31B",
|
|
44
|
+
description: "Free — great quality for Bible study",
|
|
45
|
+
free: true,
|
|
46
|
+
};
|
|
47
|
+
models["nvidia/nemotron-3-super-120b-a12b:free"] = {
|
|
48
|
+
name: "Nemotron 3 Super",
|
|
49
|
+
description: "Free — large model, strong reasoning",
|
|
50
|
+
free: true,
|
|
51
|
+
};
|
|
52
|
+
models["deepseek/deepseek-r1:free"] = {
|
|
53
|
+
name: "DeepSeek R1 (Free)",
|
|
54
|
+
description: "Free — reasoning with thinking support",
|
|
55
|
+
free: true,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
models["qwen/qwen3-coder:free"] = {
|
|
59
|
+
name: "Qwen3 Coder",
|
|
60
|
+
description: "Free — 480B parameters, excellent tool use",
|
|
61
|
+
free: true,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ── OpenRouter (premium) ─────────────────────────────
|
|
65
|
+
models["openrouter/auto"] = {
|
|
66
|
+
name: "Auto (All)",
|
|
67
|
+
description: "Auto-selects best model (requires credits)",
|
|
68
|
+
free: false,
|
|
69
|
+
};
|
|
70
|
+
models["anthropic/claude-sonnet-4"] = {
|
|
71
|
+
name: "Claude Sonnet 4",
|
|
72
|
+
description: "Premium — excellent quality + reasoning (requires credits)",
|
|
73
|
+
free: false,
|
|
74
|
+
};
|
|
75
|
+
models["google/gemini-2.5-flash-preview"] = {
|
|
76
|
+
name: "Gemini 2.5 Flash",
|
|
77
|
+
description: "Premium — fast with thinking (requires credits)",
|
|
78
|
+
free: false,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ── Ollama (local, always free) ──────────────────────
|
|
82
|
+
models["ollama/llama3"] = {
|
|
83
|
+
name: "Llama 3 (Ollama)",
|
|
84
|
+
description: "Local — Meta's Llama 3, requires Ollama",
|
|
85
|
+
free: true,
|
|
86
|
+
};
|
|
87
|
+
models["ollama/gemma3"] = {
|
|
88
|
+
name: "Gemma 3 (Ollama)",
|
|
89
|
+
description: "Local — Google's Gemma 3, requires Ollama",
|
|
90
|
+
free: true,
|
|
91
|
+
};
|
|
92
|
+
models["ollama/mistral"] = {
|
|
93
|
+
name: "Mistral (Ollama)",
|
|
94
|
+
description: "Local — Mistral's 7B model, requires Ollama",
|
|
95
|
+
free: true,
|
|
96
|
+
};
|
|
97
|
+
models["ollama/qwen3"] = {
|
|
98
|
+
name: "Qwen 3 (Ollama)",
|
|
99
|
+
description: "Local — Alibaba's Qwen 3, requires Ollama",
|
|
100
|
+
free: true,
|
|
101
|
+
};
|
|
102
|
+
models["ollama/deepseek-r1"] = {
|
|
103
|
+
name: "DeepSeek R1 (Ollama)",
|
|
104
|
+
description: "Local — reasoning model, requires Ollama",
|
|
105
|
+
free: true,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return models;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const MODELS: Record<string, ModelConfig> = buildModels();
|
|
112
|
+
export type ModelId = keyof typeof MODELS;
|
|
113
|
+
|
|
114
|
+
/** Default model — free router, works with $0 credits */
|
|
115
|
+
export const DEFAULT_MODEL_ID: ModelId = "openrouter/free";
|
|
116
|
+
|
|
117
|
+
/** Backward compat alias */
|
|
118
|
+
export const DEFAULT_MODEL: ModelId = DEFAULT_MODEL_ID;
|
|
119
|
+
|
|
120
|
+
/** Models that support extended thinking */
|
|
121
|
+
export const THINKING_MODELS: ReadonlySet<ModelId> = new Set<ModelId>([
|
|
122
|
+
"anthropic/claude-sonnet-4",
|
|
123
|
+
"deepseek/deepseek-r1:free",
|
|
124
|
+
"google/gemini-2.5-flash-preview",
|
|
125
|
+
"ollama/deepseek-r1",
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
/** Check if a model supports extended thinking */
|
|
129
|
+
export function supportsThinking(model: ModelId): boolean {
|
|
130
|
+
return THINKING_MODELS.has(model);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Check if a model is an Ollama model */
|
|
134
|
+
export function isOllamaModel(model: ModelId): boolean {
|
|
135
|
+
return (model as string).startsWith("ollama/");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Get all model IDs for a specific provider prefix */
|
|
139
|
+
export function getModelsByProvider(provider: string): ModelId[] {
|
|
140
|
+
return (Object.keys(MODELS) as ModelId[]).filter((id) =>
|
|
141
|
+
(id as string).startsWith(`${provider}/`)
|
|
142
|
+
);
|
|
143
|
+
}
|
package/src/soul.ts
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Soul.md parser for Origen agent personas.
|
|
3
|
+
*
|
|
4
|
+
* Implements Soul.md Standard (RFC-1, v1.0.0-rc1):
|
|
5
|
+
* https://github.com/rokoss21/soul.md
|
|
6
|
+
*
|
|
7
|
+
* Parses a portable, provider-agnostic persona definition and produces
|
|
8
|
+
* a system prompt, runtime config, and profile overlays for Origen.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* import { Soul, loadSoul } from "@moikapy/origen/soul";
|
|
12
|
+
*
|
|
13
|
+
* const soul = loadSoul(soulMdContent);
|
|
14
|
+
* const systemPrompt = soul.buildPrompt();
|
|
15
|
+
* const profile = soul.selectProfile("concise");
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// ── YAML front matter parser ──────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function parseYamlFrontMatter(content: string): { frontMatter: Record<string, unknown>; body: string } {
|
|
21
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)?$/);
|
|
22
|
+
if (!match) {
|
|
23
|
+
return { frontMatter: {}, body: content };
|
|
24
|
+
}
|
|
25
|
+
const rawYaml = match[1];
|
|
26
|
+
const markdownBody = match[2] ?? "";
|
|
27
|
+
const frontMatter = parseSoulYaml(rawYaml);
|
|
28
|
+
return { frontMatter, body: markdownBody };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Minimal YAML parser for Soul.md front matter.
|
|
33
|
+
* Handles maps, sequences, inline values, and quoted strings.
|
|
34
|
+
* Does NOT support anchors, aliases, merge keys, or complex types.
|
|
35
|
+
*/
|
|
36
|
+
function parseSoulYaml(raw: string): Record<string, unknown> {
|
|
37
|
+
const lines = raw.split("\n").map((l) => l.replace(/\r$/, ""));
|
|
38
|
+
const { result } = parseBlock(lines, 0, 0);
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface BlockResult {
|
|
43
|
+
result: Record<string, unknown>;
|
|
44
|
+
nextLine: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseBlock(lines: string[], start: number, baseIndent: number): BlockResult {
|
|
48
|
+
const result: Record<string, unknown> = {};
|
|
49
|
+
let i = start;
|
|
50
|
+
|
|
51
|
+
while (i < lines.length) {
|
|
52
|
+
const line = lines[i];
|
|
53
|
+
const indent = lineLengths(line).indent;
|
|
54
|
+
|
|
55
|
+
if (line.trim() === "") { i++; continue; }
|
|
56
|
+
if (indent < baseIndent) break; // End of this block
|
|
57
|
+
|
|
58
|
+
const trimmed = line.trim();
|
|
59
|
+
|
|
60
|
+
// Skip list items at top level of a map block (shouldn't happen, but guard)
|
|
61
|
+
if (trimmed.startsWith("- ")) { i++; continue; }
|
|
62
|
+
|
|
63
|
+
// key: value
|
|
64
|
+
const kvMatch = trimmed.match(/^([\w][\w.-]*):\s*(.*)$/);
|
|
65
|
+
if (!kvMatch) { i++; continue; }
|
|
66
|
+
|
|
67
|
+
const key = kvMatch[1];
|
|
68
|
+
const inlineVal = kvMatch[2].trim();
|
|
69
|
+
|
|
70
|
+
if (inlineVal === "") {
|
|
71
|
+
// Value continues on next lines
|
|
72
|
+
const nextLine = i + 1 < lines.length ? lines[i + 1] : "";
|
|
73
|
+
const nextIndent = nextLine.trim() === "" ? 0 : lineLengths(nextLine).indent;
|
|
74
|
+
const nextTrimmed = nextLine.trim();
|
|
75
|
+
|
|
76
|
+
if (nextIndent > indent) {
|
|
77
|
+
if (nextTrimmed.startsWith("- ")) {
|
|
78
|
+
// It's a list block
|
|
79
|
+
const { items, nextLine: afterList } = parseList(lines, i + 1, nextIndent);
|
|
80
|
+
result[key] = items;
|
|
81
|
+
i = afterList;
|
|
82
|
+
} else {
|
|
83
|
+
// It's a nested map block
|
|
84
|
+
const { result: nested, nextLine: afterNested } = parseBlock(lines, i + 1, nextIndent);
|
|
85
|
+
result[key] = nested;
|
|
86
|
+
i = afterNested;
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
// Empty value
|
|
90
|
+
result[key] = null;
|
|
91
|
+
i++;
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
// Inline value
|
|
95
|
+
result[key] = parseScalar(inlineVal);
|
|
96
|
+
i++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { result, nextLine: i };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface ListResult {
|
|
104
|
+
items: unknown[];
|
|
105
|
+
nextLine: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseList(lines: string[], start: number, baseIndent: number): ListResult {
|
|
109
|
+
const items: unknown[] = [];
|
|
110
|
+
let i = start;
|
|
111
|
+
|
|
112
|
+
while (i < lines.length) {
|
|
113
|
+
const line = lines[i];
|
|
114
|
+
const indent = lineLengths(line).indent;
|
|
115
|
+
|
|
116
|
+
if (line.trim() === "") { i++; continue; }
|
|
117
|
+
if (indent < baseIndent) break;
|
|
118
|
+
if (!line.trim().startsWith("- ")) break;
|
|
119
|
+
|
|
120
|
+
const value = line.trim().slice(2).trim();
|
|
121
|
+
items.push(parseScalar(value));
|
|
122
|
+
i++;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { items, nextLine: i };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseScalar(value: string): unknown {
|
|
129
|
+
if (value === "null" || value === "~") return null;
|
|
130
|
+
if (value === "true") return true;
|
|
131
|
+
if (value === "false") return false;
|
|
132
|
+
|
|
133
|
+
// Quoted string
|
|
134
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
135
|
+
return value.slice(1, -1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Number
|
|
139
|
+
if (/^-?\d+$/.test(value)) return parseInt(value, 10);
|
|
140
|
+
if (/^-?\d+\.\d+$/.test(value)) return parseFloat(value);
|
|
141
|
+
|
|
142
|
+
// Inline list [a, b, c]
|
|
143
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
144
|
+
return value
|
|
145
|
+
.slice(1, -1)
|
|
146
|
+
.split(",")
|
|
147
|
+
.map((s) => parseScalar(s.trim()));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function lineLengths(line: string): { indent: number; content: string } {
|
|
154
|
+
const match = line.match(/^(\s*)(.*)$/);
|
|
155
|
+
const indent = match ? match[1].length : 0;
|
|
156
|
+
const content = match ? match[2] : "";
|
|
157
|
+
return { indent, content };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── Soul types ─────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
export interface SoulVoice {
|
|
163
|
+
formality: number;
|
|
164
|
+
warmth: number;
|
|
165
|
+
verbosity: number;
|
|
166
|
+
jargon: number;
|
|
167
|
+
formatting: "minimal" | "plain" | "markdown";
|
|
168
|
+
banned_phrases?: string[];
|
|
169
|
+
preferred_phrases?: string[];
|
|
170
|
+
emoji_policy?: "never" | "rare" | "normal";
|
|
171
|
+
punctuation?: "normal" | "sparse";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface SoulInteraction {
|
|
175
|
+
clarifying_questions: "never" | "when_ambiguous" | "always";
|
|
176
|
+
uncertainty: "explicit" | "implicit" | "never";
|
|
177
|
+
disagreement: "soft" | "neutral" | "direct";
|
|
178
|
+
confirmations: "none" | "implicit" | "explicit";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface SoulCognition {
|
|
182
|
+
mode?: "analytical" | "creative" | "operational" | "exploratory" | "teaching" | "mixed";
|
|
183
|
+
depth?: number;
|
|
184
|
+
speed_vs_rigor?: number;
|
|
185
|
+
verification?: {
|
|
186
|
+
fact_checking?: "none" | "light" | "strict";
|
|
187
|
+
cross_validation?: number;
|
|
188
|
+
consistency_checks?: number;
|
|
189
|
+
assumption_tracking?: "none" | "implicit" | "explicit";
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface SoulSafety {
|
|
194
|
+
refusal_style: "brief" | "explain" | "policy_cite";
|
|
195
|
+
privacy: "normal" | "strict";
|
|
196
|
+
speculation: "allow" | "mark" | "avoid";
|
|
197
|
+
no_fabrication?: boolean;
|
|
198
|
+
no_false_certainty?: boolean;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface SoulActions {
|
|
202
|
+
when_to_use_tools: "avoid_tools" | "when_needed" | "prefer_tools";
|
|
203
|
+
explain_actions: "no" | "brief" | "full";
|
|
204
|
+
failover: "retry" | "alternative_method" | "ask_user";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface SoulConfig {
|
|
208
|
+
soul_spec?: string;
|
|
209
|
+
id: string;
|
|
210
|
+
name: string;
|
|
211
|
+
locale?: string;
|
|
212
|
+
version?: string;
|
|
213
|
+
description?: string;
|
|
214
|
+
composition?: {
|
|
215
|
+
extends?: string[];
|
|
216
|
+
mixins?: string[];
|
|
217
|
+
merge_policy?: string;
|
|
218
|
+
};
|
|
219
|
+
profiles?: string[];
|
|
220
|
+
profile_overrides?: Record<string, Record<string, unknown>>;
|
|
221
|
+
values?: {
|
|
222
|
+
priorities?: string[];
|
|
223
|
+
tradeoffs?: string[];
|
|
224
|
+
taboo?: string[];
|
|
225
|
+
};
|
|
226
|
+
identity?: {
|
|
227
|
+
role?: string;
|
|
228
|
+
archetype?: string;
|
|
229
|
+
domain_focus?: string[];
|
|
230
|
+
non_goals?: string[];
|
|
231
|
+
};
|
|
232
|
+
relationship?: {
|
|
233
|
+
stance?: "subordinate" | "peer" | "authoritative" | "adversarial";
|
|
234
|
+
user_model_default?: "novice" | "intermediate" | "expert" | "unknown";
|
|
235
|
+
trust_baseline?: number;
|
|
236
|
+
boundary_distance?: number;
|
|
237
|
+
};
|
|
238
|
+
voice?: Partial<SoulVoice>;
|
|
239
|
+
interaction?: Partial<SoulInteraction>;
|
|
240
|
+
cognition?: SoulCognition;
|
|
241
|
+
safety?: Partial<SoulSafety>;
|
|
242
|
+
actions?: SoulActions;
|
|
243
|
+
state?: {
|
|
244
|
+
base?: string;
|
|
245
|
+
states?: Record<string, Record<string, unknown>>;
|
|
246
|
+
triggers?: Array<{ if: string; shift_to: string; duration?: string }>;
|
|
247
|
+
};
|
|
248
|
+
examples?: Array<{ user: string; agent: string }>;
|
|
249
|
+
extensions?: Record<string, unknown>;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Soul class ─────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
export class Soul {
|
|
255
|
+
readonly config: SoulConfig;
|
|
256
|
+
readonly body: string;
|
|
257
|
+
|
|
258
|
+
constructor(config: SoulConfig, body: string) {
|
|
259
|
+
this.config = config;
|
|
260
|
+
this.body = body;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Select a profile and return a new Soul with that profile merged in. */
|
|
264
|
+
selectProfile(profileName: string): Soul {
|
|
265
|
+
const profiles = this.config.profiles ?? ["default"];
|
|
266
|
+
const overrides = this.config.profile_overrides ?? {};
|
|
267
|
+
const profileOverrides = overrides[profileName];
|
|
268
|
+
|
|
269
|
+
if (!profiles.includes(profileName) || !profileOverrides) {
|
|
270
|
+
return this;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const merged = mergeDeep(
|
|
274
|
+
structuredClone(this.config) as unknown as Record<string, unknown>,
|
|
275
|
+
profileOverrides as Record<string, unknown>
|
|
276
|
+
) as unknown as SoulConfig;
|
|
277
|
+
return new Soul(merged, this.body);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Build a system prompt from the Soul definition. */
|
|
281
|
+
buildPrompt(): string {
|
|
282
|
+
const parts: string[] = [];
|
|
283
|
+
|
|
284
|
+
// Identity
|
|
285
|
+
const identity = this.config.identity;
|
|
286
|
+
if (identity) {
|
|
287
|
+
const role = identity.role ?? this.config.name;
|
|
288
|
+
const archetype = identity.archetype;
|
|
289
|
+
parts.push(`You are ${this.config.name}${archetype ? `, a ${archetype}` : ""}${role ? `. Role: ${role}` : ""}.`);
|
|
290
|
+
if (identity.domain_focus?.length) {
|
|
291
|
+
parts.push(`Domain expertise: ${identity.domain_focus.join(", ")}.`);
|
|
292
|
+
}
|
|
293
|
+
if (identity.non_goals?.length) {
|
|
294
|
+
parts.push(`Non-goals: ${identity.non_goals.join(", ")}.`);
|
|
295
|
+
}
|
|
296
|
+
} else {
|
|
297
|
+
parts.push(`You are ${this.config.name}.`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Relationship
|
|
301
|
+
const relationship = this.config.relationship;
|
|
302
|
+
if (relationship) {
|
|
303
|
+
if (relationship.stance) {
|
|
304
|
+
const stanceMap: Record<string, string> = {
|
|
305
|
+
subordinate: "You serve the user's direction.",
|
|
306
|
+
peer: "You collaborate with the user as a partner.",
|
|
307
|
+
authoritative: "You provide expert guidance and direction.",
|
|
308
|
+
adversarial: "You challenge the user's assumptions to improve outcomes.",
|
|
309
|
+
};
|
|
310
|
+
parts.push(stanceMap[relationship.stance] ?? "");
|
|
311
|
+
}
|
|
312
|
+
if (relationship.user_model_default) {
|
|
313
|
+
const userModelMap: Record<string, string> = {
|
|
314
|
+
novice: "Assume the user is new to this domain. Explain terms and concepts.",
|
|
315
|
+
intermediate: "Assume moderate familiarity. Explain only when needed.",
|
|
316
|
+
expert: "Be concise. The user knows the domain well.",
|
|
317
|
+
unknown: "Adapt your explanation depth to the user's apparent knowledge level.",
|
|
318
|
+
};
|
|
319
|
+
parts.push(userModelMap[relationship.user_model_default] ?? "");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Values
|
|
324
|
+
const values = this.config.values;
|
|
325
|
+
if (values?.priorities?.length) {
|
|
326
|
+
parts.push(`\n## Priorities (in order)\n${values.priorities.map((p, i) => `${i + 1}. ${p}`).join("\n")}`);
|
|
327
|
+
}
|
|
328
|
+
if (values?.taboo?.length) {
|
|
329
|
+
parts.push(`\n## Forbidden patterns\n${values.taboo.map((t) => `- ${t}`).join("\n")}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Voice
|
|
333
|
+
const voice = this.config.voice;
|
|
334
|
+
if (voice) {
|
|
335
|
+
const voiceParts: string[] = ["\n## Voice & Style"];
|
|
336
|
+
if (voice.formality !== undefined) {
|
|
337
|
+
const level = voice.formality <= 30 ? "very casual" : voice.formality <= 60 ? "moderately formal" : voice.formality <= 80 ? "professional" : "highly formal";
|
|
338
|
+
voiceParts.push(`Formality: ${level} (${voice.formality}/100).`);
|
|
339
|
+
}
|
|
340
|
+
if (voice.warmth !== undefined) {
|
|
341
|
+
const level = voice.warmth <= 30 ? "cold/detached" : voice.warmth <= 60 ? "neutral" : voice.warmth <= 80 ? "warm and approachable" : "very friendly and encouraging";
|
|
342
|
+
voiceParts.push(`Tone: ${level} (${voice.warmth}/100).`);
|
|
343
|
+
}
|
|
344
|
+
if (voice.verbosity !== undefined) {
|
|
345
|
+
const level = voice.verbosity <= 25 ? "extremely concise" : voice.verbosity <= 50 ? "concise" : voice.verbosity <= 75 ? "moderate length" : "thorough and detailed";
|
|
346
|
+
voiceParts.push(`Brevity: ${level} (${voice.verbosity}/100).`);
|
|
347
|
+
}
|
|
348
|
+
if (voice.jargon !== undefined) {
|
|
349
|
+
const level = voice.jargon <= 30 ? "use plain language" : voice.jargon <= 60 ? "use moderate technical terms" : "use domain-specific terminology freely";
|
|
350
|
+
voiceParts.push(`Jargon: ${level} (${voice.jargon}/100).`);
|
|
351
|
+
}
|
|
352
|
+
if (voice.formatting) voiceParts.push(`Formatting: ${voice.formatting}.`);
|
|
353
|
+
if (voice.banned_phrases?.length) voiceParts.push(`Never say: ${voice.banned_phrases.map((p) => `"${p}"`).join(", ")}.`);
|
|
354
|
+
if (voice.preferred_phrases?.length) voiceParts.push(`Prefer: ${voice.preferred_phrases.map((p) => `"${p}"`).join(", ")}.`);
|
|
355
|
+
if (voice.emoji_policy && voice.emoji_policy !== "rare") voiceParts.push(`Emoji usage: ${voice.emoji_policy}.`);
|
|
356
|
+
parts.push(voiceParts.join(" "));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Interaction
|
|
360
|
+
const interaction = this.config.interaction;
|
|
361
|
+
if (interaction) {
|
|
362
|
+
const interactionParts: string[] = ["\n## Interaction Policy"];
|
|
363
|
+
if (interaction.clarifying_questions) {
|
|
364
|
+
const qMap: Record<string, string> = {
|
|
365
|
+
never: "Never ask clarifying questions. Make reasonable assumptions.",
|
|
366
|
+
when_ambiguous: "Ask clarifying questions only when the query is ambiguous.",
|
|
367
|
+
always: "Always confirm your understanding before responding.",
|
|
368
|
+
};
|
|
369
|
+
interactionParts.push(qMap[interaction.clarifying_questions] ?? "");
|
|
370
|
+
}
|
|
371
|
+
if (interaction.uncertainty) {
|
|
372
|
+
const uMap: Record<string, string> = {
|
|
373
|
+
explicit: "Explicitly mark uncertain information. Say when you're not sure.",
|
|
374
|
+
implicit: "Use hedging language (might, possibly, could) for uncertain claims.",
|
|
375
|
+
never: "Never express uncertainty. State your best answer confidently.",
|
|
376
|
+
};
|
|
377
|
+
interactionParts.push(uMap[interaction.uncertainty] ?? "");
|
|
378
|
+
}
|
|
379
|
+
if (interaction.disagreement) {
|
|
380
|
+
const dMap: Record<string, string> = {
|
|
381
|
+
soft: "Disagree gently. Acknowledge the user's perspective first.",
|
|
382
|
+
neutral: "State disagreements directly but politely.",
|
|
383
|
+
direct: "Challenge incorrect views directly. Don't soften disagreements.",
|
|
384
|
+
};
|
|
385
|
+
interactionParts.push(dMap[interaction.disagreement] ?? "");
|
|
386
|
+
}
|
|
387
|
+
if (interaction.confirmations === "none") {
|
|
388
|
+
interactionParts.push("Don't ask for confirmation before acting. Just do it.");
|
|
389
|
+
}
|
|
390
|
+
parts.push(interactionParts.join(" "));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Cognition
|
|
394
|
+
const cognition = this.config.cognition;
|
|
395
|
+
if (cognition) {
|
|
396
|
+
const cogParts: string[] = ["\n## Cognition"];
|
|
397
|
+
if (cognition.mode) {
|
|
398
|
+
const modeMap: Record<string, string> = {
|
|
399
|
+
analytical: "Think analytically. Break problems down, examine evidence, reason step by step.",
|
|
400
|
+
creative: "Think creatively. Generate novel ideas, make unexpected connections.",
|
|
401
|
+
operational: "Focus on execution. Prioritize working solutions over theory.",
|
|
402
|
+
exploratory: "Explore broadly. Consider many angles before committing to an answer.",
|
|
403
|
+
teaching: "Teach and explain. Build understanding progressively from basics.",
|
|
404
|
+
mixed: "Adapt your thinking mode to the task at hand.",
|
|
405
|
+
};
|
|
406
|
+
cogParts.push(modeMap[cognition.mode] ?? "");
|
|
407
|
+
}
|
|
408
|
+
if (cognition.verification?.fact_checking) {
|
|
409
|
+
const fcMap: Record<string, string> = {
|
|
410
|
+
none: "No explicit fact-checking.",
|
|
411
|
+
light: "Verify key claims before stating them.",
|
|
412
|
+
strict: "Always verify claims. Never state unverified information as fact.",
|
|
413
|
+
};
|
|
414
|
+
cogParts.push(fcMap[cognition.verification.fact_checking] ?? "");
|
|
415
|
+
}
|
|
416
|
+
parts.push(cogParts.join(" "));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Safety
|
|
420
|
+
const safety = this.config.safety;
|
|
421
|
+
if (safety) {
|
|
422
|
+
const safetyParts: string[] = ["\n## Safety"];
|
|
423
|
+
if (safety.speculation) {
|
|
424
|
+
const specMap: Record<string, string> = {
|
|
425
|
+
allow: "You may speculate freely.",
|
|
426
|
+
mark: "Mark speculative content clearly (e.g., 'I believe', 'likely', 'possibly').",
|
|
427
|
+
avoid: "Do not speculate. Only state what you can verify.",
|
|
428
|
+
};
|
|
429
|
+
safetyParts.push(specMap[safety.speculation] ?? "");
|
|
430
|
+
}
|
|
431
|
+
if (safety.refusal_style) {
|
|
432
|
+
const refMap: Record<string, string> = {
|
|
433
|
+
brief: "Refuse briefly. No lectures.",
|
|
434
|
+
explain: "Explain why you're refusing when you decline a request.",
|
|
435
|
+
policy_cite: "Cite specific policies when refusing requests.",
|
|
436
|
+
};
|
|
437
|
+
safetyParts.push(refMap[safety.refusal_style] ?? "");
|
|
438
|
+
}
|
|
439
|
+
if (safety.no_fabrication) safetyParts.push("Never fabricate information. If you don't know, say so.");
|
|
440
|
+
if (safety.no_false_certainty) safetyParts.push("Never present uncertain information as certain.");
|
|
441
|
+
parts.push(safetyParts.join(" "));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Actions
|
|
445
|
+
const actions = this.config.actions;
|
|
446
|
+
if (actions) {
|
|
447
|
+
const actParts: string[] = ["\n## Actions"];
|
|
448
|
+
const toolMap: Record<string, string> = {
|
|
449
|
+
avoid_tools: "Minimize tool use. Answer from knowledge when possible.",
|
|
450
|
+
when_needed: "Use tools when they would improve your answer.",
|
|
451
|
+
prefer_tools: "Proactively use available tools. Always verify with tools rather than memory.",
|
|
452
|
+
};
|
|
453
|
+
actParts.push(toolMap[actions.when_to_use_tools] ?? "");
|
|
454
|
+
if (actions.explain_actions === "brief" || actions.explain_actions === "full") {
|
|
455
|
+
actParts.push(actions.explain_actions === "full" ? "Explain what you're doing before and after tool use." : "Briefly explain tool use.");
|
|
456
|
+
}
|
|
457
|
+
parts.push(actParts.join(" "));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Markdown body
|
|
461
|
+
if (this.body.trim()) {
|
|
462
|
+
parts.push(`\n## Additional Instructions\n\n${this.body.trim()}`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return parts.join("\n\n");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
get defaultProfile(): string {
|
|
469
|
+
return this.config.profiles?.[0] ?? "default";
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
get profileNames(): string[] {
|
|
473
|
+
return this.config.profiles ?? ["default"];
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
/** Parse a Soul.md string into a Soul instance. */
|
|
480
|
+
export function loadSoul(content: string): Soul {
|
|
481
|
+
const { frontMatter, body } = parseYamlFrontMatter(content);
|
|
482
|
+
const config = frontMatter as unknown as SoulConfig;
|
|
483
|
+
|
|
484
|
+
if (!config.id) throw new Error("Soul.md missing required field: id");
|
|
485
|
+
if (!config.name) throw new Error("Soul.md missing required field: name");
|
|
486
|
+
|
|
487
|
+
return new Soul(config, body);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Deep merge (Standard Merge semantics from Soul.md spec). */
|
|
491
|
+
function mergeDeep(base: Record<string, unknown>, overlay: Record<string, unknown>): Record<string, unknown> {
|
|
492
|
+
const result = { ...base };
|
|
493
|
+
for (const key of Object.keys(overlay)) {
|
|
494
|
+
const baseVal = result[key];
|
|
495
|
+
const overVal = overlay[key];
|
|
496
|
+
if (overVal === null) {
|
|
497
|
+
result[key] = null;
|
|
498
|
+
} else if (
|
|
499
|
+
typeof baseVal === "object" && baseVal !== null && !Array.isArray(baseVal) &&
|
|
500
|
+
typeof overVal === "object" && overVal !== null && !Array.isArray(overVal)
|
|
501
|
+
) {
|
|
502
|
+
result[key] = mergeDeep(baseVal as Record<string, unknown>, overVal as Record<string, unknown>);
|
|
503
|
+
} else {
|
|
504
|
+
result[key] = overVal;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return result;
|
|
508
|
+
}
|