@aikdna/kdna-studio-cli 0.10.2 → 0.11.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/src/llm/config.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
+ const { canonicalLlmBaseUrl } = require('./transport');
6
7
 
7
8
  const ENV_PREFIX = 'KDNA_LLM_';
8
9
 
@@ -14,8 +15,8 @@ const WELL_KNOWN = {
14
15
  gemini: { baseURL: 'https://generativelanguage.googleapis.com/v1beta', models: ['gemini-2.5-flash', 'gemini-2.5-pro'] },
15
16
  perplexity: { baseURL: 'https://api.perplexity.ai', models: ['sonar-pro', 'sonar'] },
16
17
  groq: { baseURL: 'https://api.groq.com/openai/v1', models: ['llama-4-scout-17b-16e-instruct', 'deepseek-r1-distill-llama-70b'] },
17
- local: { baseURL: 'http://localhost:11434/v1', models: ['llama3.2'] },
18
- ollama: { baseURL: 'http://localhost:11434/v1', models: ['llama3.2'] },
18
+ local: { baseURL: 'http://127.0.0.1:11434/v1', models: ['llama3.2'] },
19
+ ollama: { baseURL: 'http://127.0.0.1:11434/v1', models: ['llama3.2'] },
19
20
  openai_compatible: { baseURL: null, models: [] },
20
21
  };
21
22
 
@@ -66,10 +67,14 @@ function loadConfig() {
66
67
  }
67
68
 
68
69
  function saveConfig(updates) {
70
+ const safeUpdates = { ...updates };
71
+ if (Object.prototype.hasOwnProperty.call(safeUpdates, 'baseURL')) {
72
+ safeUpdates.baseURL = canonicalLlmBaseUrl(safeUpdates.baseURL);
73
+ }
69
74
  const configPath = path.join(os.homedir(), '.kdna', 'config.json');
70
75
  let existing = {};
71
76
  try { if (fs.existsSync(configPath)) existing = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (e) { console.error('Error reading config file for save:', e.message); }
72
- existing.llm = { ...(existing.llm || {}), ...updates };
77
+ existing.llm = { ...(existing.llm || {}), ...safeUpdates };
73
78
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
74
79
  fs.writeFileSync(configPath, JSON.stringify(existing, null, 2) + '\n');
75
80
  }
@@ -79,6 +84,14 @@ function validateConfig(config) {
79
84
  if (!config.provider) errors.push('Missing KDNA_LLM_PROVIDER. Set via env var or ~/.kdna/config.json');
80
85
  if (!config.apiKey) errors.push('Missing KDNA_LLM_API_KEY');
81
86
  if (!config.model) errors.push('Missing KDNA_LLM_MODEL');
87
+ if (!config.baseURL) errors.push('Missing KDNA_LLM_BASE_URL');
88
+ else {
89
+ try {
90
+ canonicalLlmBaseUrl(config.baseURL);
91
+ } catch {
92
+ errors.push('Invalid KDNA_LLM_BASE_URL: external providers require HTTPS; HTTP is loopback-only');
93
+ }
94
+ }
82
95
  return { valid: errors.length === 0, errors };
83
96
  }
84
97
 
package/src/llm/index.js CHANGED
@@ -29,12 +29,12 @@ async function chatWithJSON(messages, options = {}) {
29
29
  const result = await chat(messages, { ...options, responseFormat: { type: 'json_object' } });
30
30
  try {
31
31
  result.data = JSON.parse(result.content);
32
- } catch (e) {
33
- console.error('Error parsing LLM JSON response:', e.message);
32
+ } catch {
33
+ console.error('Error parsing LLM JSON response.');
34
34
  // Try to extract JSON from markdown code blocks
35
35
  const match = result.content.match(/```(?:json)?\s*([\s\S]*?)```/);
36
36
  if (match) {
37
- try { result.data = JSON.parse(match[1].trim()); } catch (e2) { console.error('Error parsing JSON from markdown code block:', e2.message); }
37
+ try { result.data = JSON.parse(match[1].trim()); } catch { console.error('Error parsing JSON from markdown code block.'); }
38
38
  }
39
39
  }
40
40
  return result;
@@ -1,7 +1,8 @@
1
1
  // Anthropic Messages API provider
2
2
 
3
+ const { postLlmJson } = require('../transport');
4
+
3
5
  async function chat(config, messages, options = {}) {
4
- const url = `${config.baseURL}/messages`;
5
6
  const model = options.model || config.model;
6
7
  const maxTokens = options.maxTokens || config.maxTokens || 4096;
7
8
 
@@ -21,12 +22,12 @@ async function chat(config, messages, options = {}) {
21
22
  'anthropic-version': '2023-06-01',
22
23
  };
23
24
 
24
- const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(120000) });
25
- if (!res.ok) {
26
- const text = await res.text();
27
- throw new Error(`Anthropic API error ${res.status}: ${text.slice(0, 500)}`);
28
- }
29
- const data = await res.json();
25
+ const data = await postLlmJson({
26
+ baseUrl: config.baseURL,
27
+ endpoint: '/messages',
28
+ headers,
29
+ body,
30
+ });
30
31
  const content = data.content?.[0]?.text;
31
32
  if (!content) throw new Error('Anthropic returned empty response');
32
33
 
@@ -1,8 +1,9 @@
1
1
  // OpenAI-compatible chat completions provider
2
2
  // Works with: OpenAI, DeepSeek, OpenRouter, Groq, Ollama, LM Studio, Perplexity, and any OpenAI-compatible API
3
3
 
4
+ const { postLlmJson } = require('../transport');
5
+
4
6
  async function chat(config, messages, options = {}) {
5
- const url = `${config.baseURL}/chat/completions`;
6
7
  const model = options.model || config.model;
7
8
  const temperature = options.temperature ?? config.temperature ?? 0.7;
8
9
  const maxTokens = options.maxTokens || config.maxTokens || 4096;
@@ -22,12 +23,12 @@ async function chat(config, messages, options = {}) {
22
23
  headers['X-Title'] = 'KDNA Studio CLI';
23
24
  }
24
25
 
25
- const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(120000) });
26
- if (!res.ok) {
27
- const text = await res.text();
28
- throw new Error(`LLM API error ${res.status}: ${text.slice(0, 500)}`);
29
- }
30
- const data = await res.json();
26
+ const data = await postLlmJson({
27
+ baseUrl: config.baseURL,
28
+ endpoint: '/chat/completions',
29
+ headers,
30
+ body,
31
+ });
31
32
  const content = data.choices?.[0]?.message?.content;
32
33
  if (!content) throw new Error('LLM returned empty response');
33
34
 
@@ -0,0 +1,196 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Transport boundary for supported AI authoring providers.
5
+ *
6
+ * Requests contain provider credentials plus source evidence, interview
7
+ * answers, or other judgment-authoring material. External providers must use
8
+ * HTTPS. Plain HTTP is limited to exact numeric loopback development hosts;
9
+ * localhost, LAN addresses, and arbitrary hostnames are not exceptions.
10
+ */
11
+
12
+ const MAX_LLM_URL_BYTES = 2048;
13
+ const MAX_LLM_RESPONSE_BYTES = 4 * 1024 * 1024;
14
+ const ALLOWED_ENDPOINTS = new Set(['/chat/completions', '/messages']);
15
+
16
+ class LlmTransportError extends Error {
17
+ constructor(code, message, status = null) {
18
+ super(message);
19
+ this.name = 'LlmTransportError';
20
+ this.code = code;
21
+ this.status = Number.isInteger(status) ? status : null;
22
+ }
23
+ }
24
+
25
+ function isExactLoopback(hostname) {
26
+ return hostname === '127.0.0.1' || hostname === '[::1]';
27
+ }
28
+
29
+ function canonicalLlmBaseUrl(value) {
30
+ if (
31
+ typeof value !== 'string' ||
32
+ value.length === 0 ||
33
+ Buffer.byteLength(value, 'utf8') > MAX_LLM_URL_BYTES ||
34
+ [...value].some((character) => {
35
+ const codePoint = character.codePointAt(0);
36
+ return (
37
+ character === '\\' ||
38
+ character === '?' ||
39
+ character === '#' ||
40
+ codePoint < 0x21 ||
41
+ codePoint > 0x7e
42
+ );
43
+ })
44
+ ) {
45
+ throw new LlmTransportError(
46
+ 'LLM_URL_REFUSED',
47
+ 'LLM base URL must be a canonical visible-ASCII URL.',
48
+ );
49
+ }
50
+
51
+ let parsed;
52
+ try {
53
+ parsed = new URL(value);
54
+ } catch {
55
+ throw new LlmTransportError('LLM_URL_REFUSED', 'LLM base URL must be absolute.');
56
+ }
57
+ if (
58
+ !parsed.hostname ||
59
+ parsed.username ||
60
+ parsed.password ||
61
+ parsed.search ||
62
+ parsed.hash ||
63
+ parsed.pathname.includes('%') ||
64
+ parsed.pathname
65
+ .split('/')
66
+ .some((segment, index) =>
67
+ segment === '.' ||
68
+ segment === '..' ||
69
+ (index > 0 && segment === '' && parsed.pathname !== '/'))
70
+ ) {
71
+ throw new LlmTransportError(
72
+ 'LLM_URL_REFUSED',
73
+ 'LLM base URL contains credentials or an unsupported component.',
74
+ );
75
+ }
76
+ if (
77
+ parsed.protocol !== 'https:' &&
78
+ !(parsed.protocol === 'http:' && isExactLoopback(parsed.hostname))
79
+ ) {
80
+ throw new LlmTransportError(
81
+ 'LLM_URL_REFUSED',
82
+ 'LLM base URL must use HTTPS except for an exact loopback HTTP origin.',
83
+ );
84
+ }
85
+
86
+ const canonical = parsed.pathname === '/' ? parsed.origin : `${parsed.origin}${parsed.pathname}`;
87
+ if (value !== canonical || (parsed.pathname !== '/' && parsed.pathname.endsWith('/'))) {
88
+ throw new LlmTransportError('LLM_URL_REFUSED', 'LLM base URL is not canonical.');
89
+ }
90
+ return canonical;
91
+ }
92
+
93
+ function providerEndpoint(baseUrl, endpoint) {
94
+ if (!ALLOWED_ENDPOINTS.has(endpoint)) {
95
+ throw new LlmTransportError('LLM_URL_REFUSED', 'LLM provider endpoint is not admitted.');
96
+ }
97
+ return `${canonicalLlmBaseUrl(baseUrl)}${endpoint}`;
98
+ }
99
+
100
+ async function refuseInvalidResponse(response) {
101
+ try {
102
+ await response.body?.cancel();
103
+ } catch {
104
+ // Response bytes never cross the transport boundary.
105
+ }
106
+ throw new LlmTransportError('LLM_RESPONSE_INVALID', 'LLM provider returned an invalid response.');
107
+ }
108
+
109
+ async function readBoundedJson(response) {
110
+ const contentType = response.headers.get('content-type') || '';
111
+ if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(contentType)) {
112
+ return refuseInvalidResponse(response);
113
+ }
114
+ const contentLength = response.headers.get('content-length');
115
+ if (
116
+ contentLength !== null &&
117
+ (!/^(0|[1-9]\d*)$/.test(contentLength) || Number(contentLength) > MAX_LLM_RESPONSE_BYTES)
118
+ ) {
119
+ return refuseInvalidResponse(response);
120
+ }
121
+ if (!response.body) {
122
+ return refuseInvalidResponse(response);
123
+ }
124
+
125
+ const reader = response.body.getReader();
126
+ const chunks = [];
127
+ let total = 0;
128
+ try {
129
+ while (true) {
130
+ const { done, value } = await reader.read();
131
+ if (done) break;
132
+ total += value.byteLength;
133
+ if (total > MAX_LLM_RESPONSE_BYTES) {
134
+ await reader.cancel();
135
+ throw new LlmTransportError(
136
+ 'LLM_RESPONSE_INVALID',
137
+ 'LLM provider returned an invalid response.',
138
+ );
139
+ }
140
+ chunks.push(Buffer.from(value));
141
+ }
142
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, total));
143
+ const payload = JSON.parse(text);
144
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
145
+ throw new Error('not an object');
146
+ }
147
+ return payload;
148
+ } catch (error) {
149
+ if (error instanceof LlmTransportError) throw error;
150
+ throw new LlmTransportError('LLM_RESPONSE_INVALID', 'LLM provider returned an invalid response.');
151
+ } finally {
152
+ reader.releaseLock();
153
+ }
154
+ }
155
+
156
+ async function postLlmJson({ baseUrl, endpoint, headers, body, timeoutMs = 120000 }) {
157
+ const url = providerEndpoint(baseUrl, endpoint);
158
+ let response;
159
+ try {
160
+ response = await fetch(url, {
161
+ method: 'POST',
162
+ headers,
163
+ body: JSON.stringify(body),
164
+ redirect: 'error',
165
+ signal: AbortSignal.timeout(timeoutMs),
166
+ });
167
+ } catch {
168
+ throw new LlmTransportError(
169
+ 'LLM_TRANSPORT_FAILED',
170
+ 'LLM provider request failed [LLM_TRANSPORT_FAILED].',
171
+ );
172
+ }
173
+
174
+ if (!response.ok) {
175
+ try {
176
+ await response.body?.cancel();
177
+ } catch {
178
+ // Nothing from the provider response crosses the error boundary.
179
+ }
180
+ throw new LlmTransportError(
181
+ 'LLM_HTTP_ERROR',
182
+ `LLM provider rejected the request [LLM_HTTP_ERROR] (HTTP ${response.status}).`,
183
+ response.status,
184
+ );
185
+ }
186
+ return readBoundedJson(response);
187
+ }
188
+
189
+ module.exports = {
190
+ LlmTransportError,
191
+ MAX_LLM_RESPONSE_BYTES,
192
+ canonicalLlmBaseUrl,
193
+ isExactLoopback,
194
+ postLlmJson,
195
+ providerEndpoint,
196
+ };
package/src/ai/feynman.js DELETED
@@ -1,120 +0,0 @@
1
- // Feynman Evaluation — AI-driven assessment of understanding quality
2
- const llm = require('../llm');
3
-
4
- const CRITERIA = {
5
- notJustRepeat: 'NOT just repeating the axiom text verbatim — uses different words/phrasing',
6
- notTooAbstract: 'NOT too abstract — grounds the explanation in concrete terms',
7
- hasConcreteExample: 'Provides at least one specific, realistic example or scenario',
8
- clarifiesBoundary: 'Makes clear when the judgment does NOT apply (boundary awareness)',
9
- ordinaryPersonUnderstands: 'Would make sense to someone unfamiliar with the domain',
10
- };
11
-
12
- const SYSTEM = `You are a Feynman evaluation engine. Your task is to assess how well someone understands a judgment principle based on their restatement of it in their own words.
13
-
14
- Evaluate the restatement on these 5 criteria (each YES/NO):
15
- ${Object.entries(CRITERIA).map(([k, v]) => `- ${k}: ${v}`).join('\n')}
16
-
17
- Also provide a brief explanation for each criterion (1-2 sentences) and a suggested improvement if any criterion fails.
18
-
19
- Return ONLY valid JSON:
20
- {
21
- "score": <0-5 integer>,
22
- "criteria": {
23
- "notJustRepeat": true/false,
24
- "notTooAbstract": true/false,
25
- "hasConcreteExample": true/false,
26
- "clarifiesBoundary": true/false,
27
- "ordinaryPersonUnderstands": true/false
28
- },
29
- "explanations": { "<criterion>": "<explanation>" },
30
- "suggestions": ["<improvement suggestion>"]
31
- }`;
32
-
33
- async function evaluate(config, card, options = {}) {
34
- // Bug: prior version read `card.feynman_text` (a field nothing in the
35
- // codebase ever wrote) and flattened `card.one_sentence` /
36
- // `card.full_statement` — the schema actually nests those under
37
- // `card.fields`. The AI evaluator therefore always received an empty
38
- // restatement and a blank axiom, no matter what was in the project.
39
- // The fix reads the canonical schema field names and the nested
40
- // `fields` object, with a fallback for callers that pass the flattened
41
- // shape directly.
42
- const fields = (card && card.fields) || card || {};
43
- const axiomText = fields.one_sentence || fields.full_statement || fields.question || '';
44
- const restatement = card.feynman_restatement
45
- || fields.feynman_restatement
46
- // NOTE: fields.feynman_text was previously in this fallback chain
47
- // but is intentionally excluded — no code path in @aikdna writes
48
- // feynman_text. The canonical field is feynman_restatement.
49
- || '';
50
-
51
- if (!restatement) {
52
- // Bug (#62): prior version returned score 0 with the explanation
53
- // "No Feynman restatement provided", but nothing in the CLI ever
54
- // *writes* a feynman_restatement on a card. Every `kdna-studio
55
- // feynman` call hit this path, every call returned 0, and the
56
- // fynchmann command was effectively dead.
57
- //
58
- // The fix synthesises a first-cut restatement from the card's own
59
- // text (so the LLM has *something* to evaluate), and returns a
60
- // structured note in `suggestions` so the caller knows the score
61
- // is on a machine-generated restatement, not their own. The
62
- // caller can then `card update --field feynman_restatement='...'`
63
- // to lock in a human-written version.
64
- const synthesised = synthFeynmanRestatement(fields);
65
- return {
66
- score: 0,
67
- criteria: {},
68
- explanations: {
69
- notJustRepeat: 'No human-written Feynman restatement — evaluated a synthesised one.',
70
- notTooAbstract: 'Review the synthesised restatement before trusting the score.',
71
- },
72
- suggestions: [
73
- 'No feynman_restatement found on this card. The score below is on an auto-generated restatement.',
74
- `Set a human-written restatement: kdna-studio card update <project> ${card.id || '<card-id>'} --field feynman_restatement='{"text":"<your plain-language restatement>"}'`,
75
- `Synthesised restatement: "${synthesised}"`,
76
- ],
77
- synthesised_restatement: synthesised,
78
- };
79
- }
80
-
81
- const userPrompt = [
82
- `## Original Axiom`,
83
- `"${axiomText}"`,
84
- ``,
85
- `## User's Restatement (Feynman)`,
86
- `"${restatement}"`,
87
- ``,
88
- `Evaluate this restatement.`,
89
- ].join('\n');
90
-
91
- const result = await llm.chatWithJSON([
92
- { role: 'system', content: SYSTEM },
93
- { role: 'user', content: userPrompt },
94
- ], options);
95
-
96
- return result.data || { score: 0, criteria: {}, explanations: {}, suggestions: [] };
97
- }
98
-
99
- // Synthesise a first-cut restatement from the card's own fields. The
100
- // output is intentionally plain — Feynman is "explain it like I'm
101
- // five", so we strip axiom-speak and surface only the human-readable
102
- // core.
103
- function synthFeynmanRestatement(fields) {
104
- const text = fields.one_sentence
105
- || fields.full_statement
106
- || fields.question
107
- || fields.essence
108
- || fields.scope
109
- || '(no text to restate)';
110
- // Strip the leading "always/never/must" markers that signal axiom
111
- // language. The LLM evaluator will then score the result on its
112
- // own merits, but the human reader can already see plain English.
113
- return String(text)
114
- .replace(/^(always|never|must|should|shall)\s+/i, '')
115
- .replace(/\s+always\s+/gi, ' ')
116
- .replace(/\s+never\s+/gi, ' ')
117
- .trim();
118
- }
119
-
120
- module.exports = { evaluate, CRITERIA, SYSTEM, synthFeynmanRestatement };
package/src/ai/testlab.js DELETED
@@ -1,73 +0,0 @@
1
- // TestLab — A/B comparison testing with LLM
2
- const llm = require('../llm');
3
-
4
- const COMPARE_SYSTEM = `You are a controlled experiment evaluator. You will see the same user input twice — once with a knowledge domain loaded, and once without. Your task is to produce a structured comparison.
5
-
6
- IMPORTANT: You must respond with raw JSON only, no markdown wrapping, no explanatory text.
7
-
8
- The JSON format:
9
- {
10
- "without_kdna": {
11
- "response": "how you would respond WITHOUT the domain knowledge",
12
- "classification": "how you categorized the task",
13
- "diagnosis": "your analysis of the situation",
14
- "actions": "what actions you would take"
15
- },
16
- "with_kdna": {
17
- "response": "how you respond WITH the domain knowledge applied",
18
- "classification": "domain-specific classification from the loaded domain",
19
- "diagnosis": "analysis informed by domain axioms and ontology",
20
- "actions": "actions influenced by domain judgment"
21
- },
22
- "delta": {
23
- "classification_changed": true/false,
24
- "diagnosis_changed": true/false,
25
- "actions_changed": true/false,
26
- "terminology_changed": true/false,
27
- "overall_impact": "none" | "minor" | "moderate" | "significant"
28
- }
29
- }`;
30
-
31
- async function compare(config, domainName, input, domainPrompt, options = {}) {
32
- // Run WITHOUT KDNA
33
- const withoutResult = await llm.chat([
34
- { role: 'system', content: COMPARE_SYSTEM },
35
- { role: 'user', content: `Respond to the following WITHOUT any special domain knowledge:\n\n${input}\n\nRespond with the structured JSON comparison format. For the "without_kdna" section, fill in your normal response. For the "with_kdna" section, fill in what you think would change (we will verify this in a separate pass).` },
36
- ], { ...options, responseFormat: { type: 'json_object' } });
37
-
38
- let withoutData;
39
- try { withoutData = JSON.parse(withoutResult.content); } catch { withoutData = { without_kdna: { response: withoutResult.content } }; }
40
-
41
- // Run WITH KDNA
42
- const withResult = await llm.chat([
43
- { role: 'system', content: `${domainPrompt}\n\n${COMPARE_SYSTEM}` },
44
- { role: 'user', content: `Respond to the following WITH the domain knowledge loaded:\n\n${input}\n\nRespond with the structured JSON comparison format. Fill in the "with_kdna" section and the "delta" section based on what actually changed.` },
45
- ], { ...options, responseFormat: { type: 'json_object' } });
46
-
47
- let withData;
48
- try { withData = JSON.parse(withResult.content); } catch { withData = { with_kdna: { response: withResult.content } }; }
49
-
50
- return {
51
- input,
52
- domain: domainName,
53
- without_kdna: withoutData.without_kdna || { response: withoutResult.content },
54
- with_kdna: withData.with_kdna || { response: withResult.content },
55
- delta: withData.delta || { overall_impact: 'unknown' },
56
- };
57
- }
58
-
59
- async function testPreset(config, domainName, input, domainPrompt, options = {}) {
60
- const presets = {
61
- baseline: `Test the core judgment: ${input}`,
62
- edge_case: `Test a boundary case where domain judgment may or may not apply: ${input}`,
63
- contradiction: `Test for potential contradiction: present a scenario that challenges the domain's axioms: ${input}`,
64
- };
65
-
66
- const results = {};
67
- for (const [name, prompt] of Object.entries(presets)) {
68
- results[name] = await compare(config, domainName, prompt, domainPrompt, options);
69
- }
70
- return results;
71
- }
72
-
73
- module.exports = { compare, testPreset, COMPARE_SYSTEM };