@sage-protocol/cli 0.4.0 → 0.4.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 (42) hide show
  1. package/dist/cli/browser-wallet-integration.js +0 -1
  2. package/dist/cli/cast-wallet-manager.js +0 -1
  3. package/dist/cli/commands/interview.js +149 -0
  4. package/dist/cli/commands/personal.js +138 -79
  5. package/dist/cli/commands/prompts.js +242 -87
  6. package/dist/cli/commands/stake-status.js +0 -2
  7. package/dist/cli/config.js +28 -8
  8. package/dist/cli/governance-manager.js +28 -19
  9. package/dist/cli/index.js +32 -8
  10. package/dist/cli/library-manager.js +16 -6
  11. package/dist/cli/mcp-server-stdio.js +759 -156
  12. package/dist/cli/mcp-server.js +4 -30
  13. package/dist/cli/metamask-integration.js +0 -1
  14. package/dist/cli/privy-wallet-manager.js +2 -2
  15. package/dist/cli/prompt-manager.js +0 -1
  16. package/dist/cli/services/artifact-manager.js +198 -0
  17. package/dist/cli/services/doctor/fixers.js +1 -1
  18. package/dist/cli/services/mcp/env-loader.js +2 -0
  19. package/dist/cli/services/mcp/prompt-result-formatter.js +8 -1
  20. package/dist/cli/services/mcp/quick-start.js +14 -15
  21. package/dist/cli/services/mcp/sage-tool-registry.js +322 -0
  22. package/dist/cli/services/mcp/tool-args-validator.js +43 -0
  23. package/dist/cli/services/metaprompt/anthropic-client.js +87 -0
  24. package/dist/cli/services/metaprompt/interview-driver.js +161 -0
  25. package/dist/cli/services/metaprompt/model-client.js +49 -0
  26. package/dist/cli/services/metaprompt/openai-client.js +67 -0
  27. package/dist/cli/services/metaprompt/persistence.js +86 -0
  28. package/dist/cli/services/metaprompt/prompt-builder.js +186 -0
  29. package/dist/cli/services/metaprompt/session.js +18 -80
  30. package/dist/cli/services/metaprompt/slot-planner.js +115 -0
  31. package/dist/cli/services/metaprompt/templates.json +130 -0
  32. package/dist/cli/services/project-context.js +98 -0
  33. package/dist/cli/subdao.js +0 -3
  34. package/dist/cli/sxxx-manager.js +0 -1
  35. package/dist/cli/utils/aliases.js +0 -6
  36. package/dist/cli/utils/tx-wait.js +0 -3
  37. package/dist/cli/wallet-manager.js +18 -19
  38. package/dist/cli/walletconnect-integration.js +0 -1
  39. package/dist/cli/wizard-manager.js +0 -1
  40. package/package.json +3 -1
  41. package/dist/cli/commands/prompt-test.js +0 -176
  42. package/dist/cli/commands/prompt.js +0 -2531
@@ -0,0 +1,322 @@
1
+ // Central registry of core Sage MCP tools grouped by capability.
2
+ // This is used by suggest_sage_tools to provide lightweight routing
3
+ // and avoids hard-coding tool metadata in multiple places.
4
+ //
5
+ // Weight philosophy:
6
+ // - 1.0: Standard tools (general purpose)
7
+ // - 0.9: Primarily "list/browse" tools (lower-action, exploratory)
8
+ // - 1.1–1.2: High-value "action" tools that accomplish concrete tasks
9
+
10
+ const TOOL_REGISTRY = {
11
+ // ───────────── Prompt workspace tools ─────────────
12
+ search_prompts: {
13
+ category: 'prompt_workspace',
14
+ keywords: ['search', 'find', 'prompt', 'query', 'look', 'locate', 'discover'],
15
+ negativeKeywords: ['create', 'new', 'publish', 'delete'],
16
+ description: 'Search prompts across local workspace and on-chain libraries.',
17
+ whenToUse: 'Use when you need to find existing prompts by keyword, tag, or content.',
18
+ requiredParams: ['query'],
19
+ optionalParams: ['source', 'subdao', 'tags', 'includeContent'],
20
+ weight: 1.0,
21
+ },
22
+ list_prompts: {
23
+ category: 'prompt_workspace',
24
+ keywords: ['list', 'browse', 'prompt', 'workspace', 'skill', 'snippet'],
25
+ negativeKeywords: ['search', 'publish'],
26
+ description: 'List prompts, skills, or snippets from workspace and/or libraries. Use kind filter for skills.',
27
+ whenToUse: 'Use when you want to browse prompts. Filter by kind (prompt/skill/snippet) to see specific types.',
28
+ requiredParams: [],
29
+ optionalParams: ['source', 'library', 'limit', 'kind', 'publishable'],
30
+ weight: 0.9,
31
+ },
32
+ get_prompt: {
33
+ category: 'prompt_workspace',
34
+ keywords: ['get', 'inspect', 'view', 'prompt', 'details'],
35
+ negativeKeywords: ['search', 'list'],
36
+ description: 'Retrieve a specific prompt by key, including its content.',
37
+ whenToUse: 'Use when you already know the prompt key and want full details for editing or reuse.',
38
+ requiredParams: ['key'],
39
+ optionalParams: ['library'],
40
+ weight: 1.0,
41
+ },
42
+ quick_create_prompt: {
43
+ category: 'prompt_workspace',
44
+ keywords: ['create', 'new', 'write', 'add', 'draft', 'prompt'],
45
+ negativeKeywords: ['find', 'search', 'list'],
46
+ description: 'Create a new prompt with automatic library handling.',
47
+ whenToUse: 'Use when starting fresh with a new prompt idea.',
48
+ requiredParams: ['name', 'content'],
49
+ optionalParams: ['library', 'description', 'tags'],
50
+ weight: 1.2,
51
+ },
52
+ quick_iterate_prompt: {
53
+ category: 'prompt_workspace',
54
+ keywords: ['edit', 'update', 'improve', 'iterate', 'refine'],
55
+ negativeKeywords: ['create', 'search'],
56
+ description: 'Modify an existing prompt in-place, keeping history in the workspace.',
57
+ whenToUse: 'Use when you want to refine or adjust an existing prompt.',
58
+ requiredParams: ['key', 'content'],
59
+ optionalParams: ['library', 'description'],
60
+ weight: 1.1,
61
+ },
62
+ test_prompt: {
63
+ category: 'prompt_workspace',
64
+ keywords: ['test', 'render', 'evaluate', 'prompt', 'variables'],
65
+ negativeKeywords: ['search', 'list'],
66
+ description: 'Test a prompt by filling in ${variable} placeholders and viewing the rendered result.',
67
+ whenToUse: 'Use when you want to verify prompt behavior and variable substitution.',
68
+ requiredParams: ['key'],
69
+ optionalParams: ['library', 'variables'],
70
+ weight: 1.0,
71
+ },
72
+ quick_test_prompt: {
73
+ category: 'prompt_workspace',
74
+ keywords: ['test', 'quick', 'run', 'prompt', 'variables'],
75
+ negativeKeywords: ['search', 'list'],
76
+ description: 'Alias for test_prompt. Quickly test a prompt with variables.',
77
+ whenToUse: 'Use when you want a shortcut to test_prompt for fast validation.',
78
+ requiredParams: ['key'],
79
+ optionalParams: ['variables'],
80
+ weight: 1.0,
81
+ },
82
+ // NOTE: list_workspace_skills removed - use list_prompts({ kind: 'skill' })
83
+ improve_prompt: {
84
+ category: 'prompt_workspace',
85
+ keywords: ['improve', 'refine', 'quality', 'analyze', 'prompt'],
86
+ negativeKeywords: ['create', 'publish'],
87
+ description: 'Analyze an existing prompt and suggest improvement areas and interview questions.',
88
+ whenToUse: 'Use when you have a working prompt and want structured suggestions for improvement.',
89
+ requiredParams: ['key'],
90
+ optionalParams: ['library', 'depth', 'focus'],
91
+ weight: 1.0,
92
+ },
93
+ rename_prompt: {
94
+ category: 'prompt_workspace',
95
+ keywords: ['rename', 'key', 'name', 'prompt'],
96
+ negativeKeywords: ['search'],
97
+ description: 'Rename a prompt key and/or display name while preserving content.',
98
+ whenToUse: 'Use when you need to change the identifier or display name of an existing prompt.',
99
+ requiredParams: ['key'],
100
+ optionalParams: ['newKey', 'name'],
101
+ weight: 0.9,
102
+ },
103
+ // NOTE: get_workspace_skill removed - use get_prompt({ key })
104
+
105
+ // ───────────── Persona / metaprompt tools ─────────────
106
+ list_persona_templates: {
107
+ category: 'persona',
108
+ keywords: ['persona', 'template', 'list', 'browse', 'metaprompt'],
109
+ negativeKeywords: [],
110
+ description: 'List available persona templates (coding assistant, governance helper, etc.).',
111
+ whenToUse: 'Use when you are deciding what kind of assistant/persona to design.',
112
+ requiredParams: [],
113
+ optionalParams: [],
114
+ weight: 1.0,
115
+ },
116
+ run_persona_interview: {
117
+ category: 'persona',
118
+ keywords: ['persona', 'interview', 'one-shot', 'create', 'system prompt'],
119
+ negativeKeywords: ['stepwise', 'stateful'],
120
+ description: 'Build a persona/system prompt in one shot from provided answers.',
121
+ whenToUse: 'Use when you already have answers for the persona slots and just need a system prompt.',
122
+ requiredParams: ['template', 'answers'],
123
+ optionalParams: ['save', 'saveKey'],
124
+ weight: 1.2,
125
+ },
126
+ persona_interview_step: {
127
+ category: 'persona',
128
+ keywords: ['persona', 'interview', 'step', 'slot', 'stateful'],
129
+ negativeKeywords: ['one-shot'],
130
+ description: 'Advance a stateful persona interview by one step, returning slot metadata.',
131
+ whenToUse: 'Use when you want the host LLM to run a multi-turn interview over persona slots.',
132
+ requiredParams: [],
133
+ optionalParams: ['template', 'stateToken', 'answer'],
134
+ weight: 1.1,
135
+ },
136
+ save_metaprompt: {
137
+ category: 'persona',
138
+ keywords: ['metaprompt', 'save', 'store', 'persona'],
139
+ negativeKeywords: [],
140
+ description: 'Save a metaprompt/persona definition into the Sage workspace.',
141
+ whenToUse: 'Use when you want to persist a finalized persona or system prompt.',
142
+ requiredParams: ['title', 'body'],
143
+ optionalParams: ['summary', 'tags', 'appendAgentsFile'],
144
+ weight: 1.0,
145
+ },
146
+ get_metaprompt: {
147
+ category: 'persona',
148
+ keywords: ['metaprompt', 'persona', 'load', 'get'],
149
+ negativeKeywords: [],
150
+ description: 'Load an existing metaprompt/persona by slug.',
151
+ whenToUse: 'Use when you want to reuse or inspect a previously saved persona.',
152
+ requiredParams: ['slug'],
153
+ optionalParams: [],
154
+ weight: 1.0,
155
+ },
156
+
157
+ // ───────────── Library / manifest tools ─────────────
158
+ list_libraries: {
159
+ category: 'libraries',
160
+ keywords: ['library', 'libraries', 'list', 'browse', 'manifest'],
161
+ negativeKeywords: [],
162
+ description: 'List available prompt libraries from local workspace or on-chain.',
163
+ whenToUse: 'Use when you want to see which libraries/manifests are available.',
164
+ requiredParams: [],
165
+ optionalParams: ['source', 'subdao', 'limit'],
166
+ weight: 1.0,
167
+ },
168
+ get_prompts_from_manifest: {
169
+ category: 'libraries',
170
+ keywords: ['manifest', 'library', 'prompt', 'list', 'get'],
171
+ negativeKeywords: [],
172
+ description: 'Get all prompts from a specific manifest by CID.',
173
+ whenToUse: 'Use when you have a manifest CID and want to inspect its prompts.',
174
+ requiredParams: ['manifestCid'],
175
+ optionalParams: ['includeContent'],
176
+ weight: 1.0,
177
+ },
178
+ list_templates: {
179
+ category: 'libraries',
180
+ keywords: ['template', 'list', 'browse', 'prompt'],
181
+ negativeKeywords: [],
182
+ description: 'List available prompt templates for quick creation.',
183
+ whenToUse: 'Use when you want to see reusable prompt templates (not personas).',
184
+ requiredParams: [],
185
+ optionalParams: ['category', 'search'],
186
+ weight: 0.9,
187
+ },
188
+ create_from_template: {
189
+ category: 'libraries',
190
+ keywords: ['template', 'create', 'new', 'prompt'],
191
+ negativeKeywords: [],
192
+ description: 'Create a new prompt from a template and save into a library.',
193
+ whenToUse: 'Use when you want to quickly scaffold a prompt from an existing template.',
194
+ requiredParams: ['template', 'customize'],
195
+ optionalParams: ['library', 'name'],
196
+ weight: 1.1,
197
+ },
198
+ get_prompt_content: {
199
+ category: 'libraries',
200
+ keywords: ['get', 'prompt', 'content', 'ipfs', 'cid'],
201
+ negativeKeywords: ['search'],
202
+ description: 'Fetch full prompt content from IPFS by CID.',
203
+ whenToUse: 'Use when you have an IPFS CID and need the underlying prompt content.',
204
+ requiredParams: ['cid'],
205
+ optionalParams: [],
206
+ weight: 1.0,
207
+ },
208
+
209
+ // ───────────── Governance tools ─────────────
210
+ publish_manifest_flow: {
211
+ category: 'governance',
212
+ keywords: ['publish', 'manifest', 'dao', 'subdao', 'governance'],
213
+ negativeKeywords: [],
214
+ description: 'Prepare a manifest for on-chain publishing and generate CLI commands.',
215
+ whenToUse: 'Use when you are ready to publish a library/manifest to a DAO.',
216
+ requiredParams: ['manifest'],
217
+ optionalParams: ['subdao', 'description', 'dry_run'],
218
+ weight: 1.2,
219
+ },
220
+ list_proposals: {
221
+ category: 'governance',
222
+ keywords: ['proposal', 'governance', 'list', 'dao', 'subdao'],
223
+ negativeKeywords: [],
224
+ description: 'List active governance proposals for a specific DAO.',
225
+ whenToUse: 'Use when you want to inspect or track current governance proposals.',
226
+ requiredParams: [],
227
+ optionalParams: ['state', 'subdao', 'limit'],
228
+ weight: 1.0,
229
+ },
230
+ suggest_subdaos_for_library: {
231
+ category: 'governance',
232
+ keywords: ['subdao', 'suggest', 'library', 'publish', 'dao'],
233
+ negativeKeywords: [],
234
+ description: 'Suggest suitable DAOs for publishing a given library manifest.',
235
+ whenToUse: 'Use when you have a library and want advice on where to publish it.',
236
+ requiredParams: ['library'],
237
+ optionalParams: ['limit'],
238
+ weight: 1.0,
239
+ },
240
+ generate_publishing_commands: {
241
+ category: 'governance',
242
+ keywords: ['publish', 'commands', 'cli', 'dao', 'subdao'],
243
+ negativeKeywords: [],
244
+ description: 'Generate CLI commands for validating, uploading, and proposing a manifest.',
245
+ whenToUse: 'Use after selecting a DAO to get concrete CLI commands for publishing.',
246
+ requiredParams: ['library'],
247
+ optionalParams: ['target'],
248
+ weight: 1.0,
249
+ },
250
+
251
+ // ───────────── Treasury / DAO tools ─────────────
252
+ list_subdaos: {
253
+ category: 'treasury',
254
+ keywords: ['subdao', 'dao', 'list', 'treasury', 'governance'],
255
+ negativeKeywords: [],
256
+ description: 'List all available DAOs in the Sage Protocol.',
257
+ whenToUse: 'Use when you want an overview of existing DAOs and treasuries.',
258
+ requiredParams: [],
259
+ optionalParams: ['limit'],
260
+ weight: 0.9,
261
+ },
262
+ list_subdao_libraries: {
263
+ category: 'treasury',
264
+ keywords: ['subdao', 'library', 'libraries', 'list'],
265
+ negativeKeywords: [],
266
+ description: 'List libraries associated with a specific DAO.',
267
+ whenToUse: 'Use when you want to see what libraries a DAO currently governs.',
268
+ requiredParams: ['subdao'],
269
+ optionalParams: [],
270
+ weight: 1.0,
271
+ },
272
+
273
+ // ───────────── Discovery / helper tools ─────────────
274
+ get_project_context: {
275
+ category: 'discovery',
276
+ keywords: ['project', 'context', 'workspace', 'config', 'subdao', 'registry'],
277
+ negativeKeywords: [],
278
+ description: 'Get context about the current Sage project (root, DAO, artifact counts, etc).',
279
+ whenToUse: 'Use at session start to understand the project, or before tool selection to check workspace state.',
280
+ requiredParams: [],
281
+ optionalParams: [],
282
+ weight: 1.1,
283
+ },
284
+ trending_prompts: {
285
+ category: 'discovery',
286
+ keywords: ['trending', 'popular', 'recent', 'discover', 'prompt'],
287
+ negativeKeywords: ['create'],
288
+ description: 'List trending prompts from recent LibraryRegistry updates.',
289
+ whenToUse: 'Use when you want to discover popular or recently active prompts.',
290
+ requiredParams: [],
291
+ optionalParams: ['decayMinutes', 'limit'],
292
+ weight: 0.9,
293
+ },
294
+ help: {
295
+ category: 'discovery',
296
+ keywords: ['help', 'usage', 'docs', 'explain'],
297
+ negativeKeywords: [],
298
+ description: 'Get help on how to use Sage MCP tools and workflows.',
299
+ whenToUse: 'Use when you need guidance on which Sage tools to use or how to call them.',
300
+ requiredParams: [],
301
+ optionalParams: ['topic'],
302
+ weight: 0.9,
303
+ },
304
+ };
305
+
306
+ function getToolsForCategory(category) {
307
+ return Object.entries(TOOL_REGISTRY)
308
+ .filter(([, meta]) => meta.category === category)
309
+ .map(([name, meta]) => ({ name, ...meta }));
310
+ }
311
+
312
+ function getToolMeta(name) {
313
+ const meta = TOOL_REGISTRY[name];
314
+ if (!meta) return null;
315
+ return { name, ...meta };
316
+ }
317
+
318
+ module.exports = {
319
+ TOOL_REGISTRY,
320
+ getToolsForCategory,
321
+ getToolMeta,
322
+ };
@@ -5,6 +5,24 @@ function createToolArgsValidator({ zodModule } = {}) {
5
5
  }
6
6
 
7
7
  const schemas = {
8
+ suggest_sage_tools: Z.object({
9
+ goal: Z.string().min(1).max(1000),
10
+ stage: Z.enum([
11
+ 'prompt_workspace',
12
+ 'persona',
13
+ 'libraries',
14
+ 'governance',
15
+ 'treasury',
16
+ 'discovery',
17
+ ]).optional(),
18
+ context: Z.object({
19
+ hasWorkspace: Z.boolean().optional(),
20
+ hasWallet: Z.boolean().optional(),
21
+ currentTask: Z.string().max(200).optional(),
22
+ }).optional(),
23
+ limit: Z.number().int().min(1).max(10).optional().default(5),
24
+ includeAlternatives: Z.boolean().optional().default(false),
25
+ }),
8
26
  search_prompts: Z.object({
9
27
  query: Z.string().max(256).optional().default(''),
10
28
  source: Z.enum(['local', 'onchain', 'all']).optional().default('all'),
@@ -26,6 +44,18 @@ function createToolArgsValidator({ zodModule } = {}) {
26
44
  page: Z.number().int().min(1).max(100).optional().default(1),
27
45
  pageSize: Z.number().int().min(1).max(50).optional().default(10),
28
46
  }),
47
+ list_prompts: Z.object({
48
+ source: Z.enum(['local', 'workspace', 'onchain', 'all']).optional().default('local'),
49
+ library: Z.string().max(200).optional().default(''),
50
+ limit: Z.number().int().min(1).max(100).optional().default(20),
51
+ kind: Z.enum(['prompt', 'skill', 'snippet']).optional(),
52
+ publishable: Z.boolean().optional(),
53
+ }),
54
+ get_prompt: Z.object({
55
+ key: Z.string().min(1).max(200),
56
+ library: Z.string().max(200).optional().default(''),
57
+ }),
58
+ get_project_context: Z.object({}),
29
59
  get_prompt_content: Z.object({ cid: Z.string().min(10).max(200) }),
30
60
  trending_prompts: Z.object({
31
61
  limit: Z.number().int().min(1).max(50).optional().default(10),
@@ -38,6 +68,19 @@ function createToolArgsValidator({ zodModule } = {}) {
38
68
  model: Z.string().max(100).optional(),
39
69
  interviewStyle: Z.string().max(200).optional(),
40
70
  }),
71
+ list_persona_templates: Z.object({}),
72
+ run_persona_interview: Z.object({
73
+ template: Z.string().min(1).max(200),
74
+ answers: Z.record(Z.any()),
75
+ save: Z.boolean().optional().default(false),
76
+ saveKey: Z.string().min(1).max(200).optional(),
77
+ }),
78
+ persona_interview_step: Z.object({
79
+ // template is required on first call, optional when stateToken is provided
80
+ template: Z.string().min(1).max(200).optional().default('custom'),
81
+ stateToken: Z.string().max(16000).optional(), // increased for slots + answers
82
+ answer: Z.string().max(8000).optional(),
83
+ }),
41
84
  save_metaprompt: Z.object({
42
85
  title: Z.string().min(1).max(200),
43
86
  summary: Z.string().max(500).optional().default(''),
@@ -0,0 +1,87 @@
1
+ let Anthropic;
2
+ try {
3
+ // Lazy / guarded require so OpenAI-only setups don't crash on import
4
+ // eslint-disable-next-line global-require
5
+ Anthropic = require('@anthropic-ai/sdk');
6
+ } catch (err) {
7
+ // Defer error until someone actually tries to construct the client
8
+ Anthropic = null;
9
+ }
10
+
11
+ /**
12
+ * Client for interacting with Anthropic's Claude API.
13
+ * Follows the ModelClient interface.
14
+ */
15
+ class AnthropicClient {
16
+ /**
17
+ * @param {object} config
18
+ * @param {string} config.apiKey
19
+ * @param {string} [config.model]
20
+ * @param {number} [config.temperature]
21
+ */
22
+ constructor({ apiKey, model, temperature }) {
23
+ if (!Anthropic) {
24
+ throw new Error('Anthropic SDK not installed. Please add @anthropic-ai/sdk or configure a different provider.');
25
+ }
26
+ this.apiKey = apiKey;
27
+ this.model = model || 'claude-sonnet-4-20250514';
28
+ this.temperature = temperature ?? 0.7;
29
+
30
+ this.client = new Anthropic({
31
+ apiKey: this.apiKey,
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Stream chat completion.
37
+ * @param {Array<{role:string, content:string}>} messages
38
+ * @param {(token:string) => void} [onToken] Callback for each token
39
+ * @returns {Promise<string>} Full response text
40
+ */
41
+ async streamChat(messages, onToken) {
42
+ // Convert 'developer' role to 'system' property, as Anthropic handles system prompts separately
43
+ const systemMessage = messages.find(m => m.role === 'system' || m.role === 'developer');
44
+ const userAssistantMessages = messages.filter(m => m.role !== 'system' && m.role !== 'developer');
45
+
46
+ const stream = await this.client.messages.create({
47
+ model: this.model,
48
+ system: systemMessage ? systemMessage.content : undefined,
49
+ messages: userAssistantMessages,
50
+ max_tokens: 4096,
51
+ temperature: this.temperature,
52
+ stream: true,
53
+ });
54
+
55
+ let fullText = '';
56
+ for await (const chunk of stream) {
57
+ if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
58
+ const content = chunk.delta.text;
59
+ fullText += content;
60
+ if (onToken) onToken(content);
61
+ }
62
+ }
63
+ return fullText;
64
+ }
65
+
66
+ /**
67
+ * Non-streaming chat completion.
68
+ * @param {Array<{role:string, content:string}>} messages
69
+ * @returns {Promise<string>} Full response text
70
+ */
71
+ async complete(messages) {
72
+ const systemMessage = messages.find(m => m.role === 'system' || m.role === 'developer');
73
+ const userAssistantMessages = messages.filter(m => m.role !== 'system' && m.role !== 'developer');
74
+
75
+ const response = await this.client.messages.create({
76
+ model: this.model,
77
+ system: systemMessage ? systemMessage.content : undefined,
78
+ messages: userAssistantMessages,
79
+ max_tokens: 4096,
80
+ temperature: this.temperature,
81
+ });
82
+
83
+ return response.content[0].text || '';
84
+ }
85
+ }
86
+
87
+ module.exports = AnthropicClient;
@@ -0,0 +1,161 @@
1
+ const { createModelClient } = require('./model-client');
2
+ const SlotPlanner = require('./slot-planner');
3
+ const PromptBuilder = require('./prompt-builder');
4
+
5
+ class InterviewDriver {
6
+ /**
7
+ * @param {object} config CLI config object
8
+ * @param {object} [options]
9
+ * @param {string} [options.templateKey]
10
+ * @param {string} [options.initialDescription]
11
+ * @param {string} [options.provider]
12
+ * @param {string} [options.model]
13
+ * @param {string} [options.apiKey]
14
+ */
15
+ constructor(config, options = {}) {
16
+ this.config = config;
17
+ this.client = createModelClient(config, {
18
+ provider: options.provider,
19
+ model: options.model,
20
+ apiKey: options.apiKey,
21
+ });
22
+ this.planner = new SlotPlanner(config, {
23
+ client: this.client,
24
+ provider: options.provider,
25
+ model: options.model,
26
+ apiKey: options.apiKey,
27
+ });
28
+ this.builder = new PromptBuilder();
29
+
30
+ this.templateKey = options.templateKey || 'custom';
31
+ this.initialDescription = options.initialDescription || '';
32
+ this.transcript = [];
33
+ this.slots = []; // The plan
34
+ this.answers = {}; // The state of filled slots
35
+ this.isDone = false;
36
+ }
37
+ /**
38
+ * Initialize the driver. Call this before using getNextQuestion().
39
+ * @param {object} [precomputedState] Optional pre-existing state to restore
40
+ * @param {Array} [precomputedState.slots] Pre-computed slots (skips LLM planning)
41
+ * @param {object} [precomputedState.answers] Pre-filled answers
42
+ * @param {Array} [precomputedState.transcript] Prior transcript
43
+ */
44
+ async init(precomputedState = null) {
45
+ // If we have precomputed state, restore it (skip expensive LLM planning)
46
+ if (precomputedState && precomputedState.slots && precomputedState.slots.length > 0) {
47
+ this.slots = precomputedState.slots;
48
+ this.answers = precomputedState.answers || {};
49
+ this.transcript = precomputedState.transcript || [];
50
+ return;
51
+ }
52
+
53
+ // 1. Generate Plan (uses LLM for custom templates, static for built-ins)
54
+ this.slots = await this.planner.planSlots(this.templateKey, this.initialDescription);
55
+
56
+ // 2. Check if initialDescription fills any slots immediately (One-shot extraction)
57
+ if (this.initialDescription) {
58
+ await this.#extractFromText(this.initialDescription);
59
+ }
60
+ }
61
+
62
+ async getNextQuestion() {
63
+ if (this.isDone) return null;
64
+
65
+ // 1. Find highest priority slot that is required and empty
66
+ const nextSlot = this.slots.find(s => s.required && !this.answers[s.key]);
67
+
68
+ if (!nextSlot) {
69
+ // All required slots filled. Check optional high priority?
70
+ const optionalSlot = this.slots.find(s => !this.answers[s.key] && s.priority <= 2);
71
+ if (optionalSlot) {
72
+ return await this.#generateQuestion(optionalSlot);
73
+ }
74
+
75
+ // If we are here, we are effectively done with the structured part.
76
+ this.isDone = true;
77
+ return null;
78
+ }
79
+
80
+ return await this.#generateQuestion(nextSlot);
81
+ }
82
+
83
+ async processAnswer(answer) {
84
+ this.transcript.push({ role: 'user', content: answer });
85
+ await this.#extractFromText(answer); // Extract whatever we can from the answer
86
+ }
87
+
88
+ generateSystemPrompt() {
89
+ return this.builder.buildSystemPrompt(this.templateKey, this.slots, this.answers);
90
+ }
91
+
92
+ // --- Private Helpers ---
93
+
94
+ async #generateQuestion(slot) {
95
+ const systemPrompt = `
96
+ You are an expert interviewer. Your goal is to extract specific information from the user to build an AI persona.
97
+ Current Target: "${slot.label}" (${slot.description})
98
+ Context So Far: ${JSON.stringify(this.answers)}
99
+
100
+ Generate a single, clear, friendly question to ask the user for this information.
101
+ Do not preface it with "Question:" or similar. Just ask.
102
+ `;
103
+ const messages = [
104
+ { role: 'system', content: systemPrompt },
105
+ { role: 'user', content: 'Ask the question.' }
106
+ ];
107
+
108
+ const question = await this.client.complete(messages);
109
+ this.transcript.push({ role: 'assistant', content: question });
110
+ return question;
111
+ }
112
+
113
+ async #extractFromText(text) {
114
+ // Use LLM to map text to slots
115
+ // We send the schema + current answers + text
116
+ // Use JSON.stringify for the text to handle quotes, braces, and special chars safely
117
+ const emptySlots = this.slots.filter(s => !this.answers[s.key]);
118
+ if (emptySlots.length === 0) return;
119
+
120
+ const systemPrompt = `
121
+ You are a data extraction engine.
122
+ Slots Schema: ${JSON.stringify(emptySlots)}
123
+ (Only extract for empty slots, but update existing if new info is better).
124
+
125
+ Input Text: ${JSON.stringify(text)}
126
+
127
+ Return a JSON object where keys match the slot keys and values are the extracted information.
128
+ If no information is found for a slot, omit the key.
129
+ CRITICAL: Return ONLY a valid JSON object, no markdown, no explanation.
130
+ `;
131
+
132
+ const messages = [
133
+ { role: 'system', content: systemPrompt },
134
+ { role: 'user', content: 'Extract data from the input text.' }
135
+ ];
136
+
137
+ try {
138
+ const response = await this.client.complete(messages);
139
+ // Try to find a JSON object in the response
140
+ const jsonMatch = response.match(/\{[\s\S]*\}/);
141
+ if (!jsonMatch) return;
142
+
143
+ const updates = JSON.parse(jsonMatch[0]);
144
+
145
+ // Update state
146
+ for (const [key, val] of Object.entries(updates)) {
147
+ if (val && (typeof val === 'string' || typeof val === 'object')) {
148
+ this.answers[key] = typeof val === 'object' ? JSON.stringify(val) : val;
149
+ }
150
+ }
151
+ } catch (e) {
152
+ // Silent fail - extraction is best-effort
153
+ if (process.env.SAGE_VERBOSE) {
154
+ console.warn('Extraction failed:', e.message);
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ module.exports = InterviewDriver;
161
+
@@ -0,0 +1,49 @@
1
+ const OpenAIClient = require('./openai-client');
2
+ const AnthropicClient = require('./anthropic-client');
3
+
4
+ /**
5
+ * Factory to create the appropriate ModelClient based on available keys.
6
+ * @param {object} config The config object (from require('../../config'))
7
+ * @param {object} [overrides] Optional overrides for testing or CLI flags
8
+ * @param {string} [overrides.provider] 'openai' | 'anthropic'
9
+ * @param {string} [overrides.model]
10
+ * @param {string} [overrides.apiKey]
11
+ * @returns {OpenAIClient|AnthropicClient}
12
+ */
13
+ function createModelClient(config, overrides = {}) {
14
+ // 1. Explicit override
15
+ if (overrides.provider && overrides.apiKey) {
16
+ if (overrides.provider === 'anthropic') {
17
+ return new AnthropicClient({ apiKey: overrides.apiKey, model: overrides.model });
18
+ }
19
+ if (overrides.provider === 'openai') {
20
+ return new OpenAIClient({ apiKey: overrides.apiKey, model: overrides.model });
21
+ }
22
+ }
23
+
24
+ // 2. Config-based resolution
25
+ const aiConfig = config.readAIConfig();
26
+
27
+ // Prefer explicit provider override if key is in config
28
+ if (overrides.provider === 'anthropic' && aiConfig.anthropicApiKey) {
29
+ return new AnthropicClient({ apiKey: aiConfig.anthropicApiKey, model: overrides.model });
30
+ }
31
+ if (overrides.provider === 'openai' && aiConfig.openaiApiKey) {
32
+ return new OpenAIClient({ apiKey: aiConfig.openaiApiKey, model: overrides.model });
33
+ }
34
+
35
+ // 3. Default preference: Anthropic > OpenAI
36
+ if (aiConfig.anthropicApiKey) {
37
+ return new AnthropicClient({ apiKey: aiConfig.anthropicApiKey, model: overrides.model });
38
+ }
39
+ if (aiConfig.openaiApiKey) {
40
+ return new OpenAIClient({ apiKey: aiConfig.openaiApiKey, model: overrides.model });
41
+ }
42
+
43
+ throw new Error(
44
+ 'No AI keys found. Please run `sage config ai set --provider anthropic --key <sk-ant-...>` or set ANTHROPIC_API_KEY.'
45
+ );
46
+ }
47
+
48
+ module.exports = { createModelClient };
49
+