@duckcodeailabs/dql-cli 1.6.28 → 1.6.30

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.
@@ -11,11 +11,13 @@ import { loadSemanticLayerFromDir, resolveSemanticLayerAsync, getDialect, Parser
11
11
  import { load as loadYaml } from 'js-yaml';
12
12
  import { listBlockTemplates } from './block-templates.js';
13
13
  import { getRunner as getLLMRunner } from './llm/index.js';
14
+ import { createDqlAgentProviderRunner } from './llm/providers/dql-agent-provider.js';
14
15
  import { listRemoteMcpSettings, saveRemoteMcpSettings } from './llm/mcp-config.js';
15
- import { ClaudeProvider, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildLocalContextPack, defaultMemoryPath, ensureDefaultMemoryFiles, ensureMetadataCatalogFresh, propose, proposePlan, recordCorrectionTrace, reviewHint, AgentRunEngine, FileAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, narrateResult, normalizeAnthropicBaseUrl, buildProposePreview, buildFromPrompt, defaultAgentRunStorePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, loadSkills, writeSkill, deleteSkill, reindexProject, defaultKgPath, planApp, planResearch, loadSemanticMetrics, } from '@duckcodeailabs/dql-agent';
16
+ import { ClaudeProvider, GeminiProvider, MemoryStore, OllamaProvider, OpenAIProvider, buildBlockBusinessFingerprint, buildBlockSqlFingerprints, buildLocalContextPack, defaultMemoryPath, ensureDefaultMemoryFiles, ensureMetadataCatalogFresh, propose, proposePlan, recordCorrectionTrace, reviewHint, AgentRunEngine, FileAgentRunStore, defaultAgentRunGates, createLlmAgentRunPlanner, createHybridRouter, synthesizeAnswer, streamOrGenerate, narrateResult, normalizeAnthropicBaseUrl, buildProposePreview, buildFromPrompt, defaultAgentRunStorePath, resolveLocalOwner, resolveProposeConfig, recordQueryRun, recordRuntimeSchemaSnapshot, loadSkills, writeSkill, deleteSkill, reindexProject, defaultKgPath, planApp, planResearch, loadSemanticMetrics, } from '@duckcodeailabs/dql-agent';
16
17
  import { gatherProposeEnrichment } from './propose-enrich.js';
17
18
  import { handleAppsApi, proposeAppAiBuild, recommendVisualization } from './apps-api.js';
18
19
  import { getActiveProvider, getEffectiveProviderConfig, listProviderSettings, saveProviderSettings, } from './settings/provider-settings.js';
20
+ import { ClaudeCodeCliProvider, CodexCliProvider } from './providers/subscription-cli.js';
19
21
  import { DQLAccessDeniedError, activePersonaAppId, assertAppAccess, loadRuntimeApp, runtimeVariables, } from './governance-runtime.js';
20
22
  import { LocalAppStorage, LocalNotebookResearchStorage, defaultLocalAppsDbPath, defaultNotebookResearchDbPath } from '@duckcodeailabs/dql-project';
21
23
  import { Certifier, ENTERPRISE_RULES, evaluateInvariants, hasInvariantViolation, } from '@duckcodeailabs/dql-governance';
@@ -365,10 +367,11 @@ export async function startLocalServer(opts) {
365
367
  },
366
368
  });
367
369
  async function runGovernedAgentAnswerForRun(request, repair) {
368
- const resolvedProvider = resolveDefaultLLMProvider(projectRoot);
369
- const runner = resolvedProvider ? getLLMRunner(resolvedProvider) : null;
370
+ const governed = resolveGovernedAnswerRunner(projectRoot);
371
+ const resolvedProvider = governed?.provider ?? null;
372
+ const runner = governed?.runner ?? null;
370
373
  if (!resolvedProvider || !runner) {
371
- throw new Error('No AI provider is configured. Configure OpenAI, Gemini, Ollama, or a custom OpenAI-compatible endpoint in Settings.');
374
+ throw new Error('No AI provider is configured. Configure a subscription (Claude Code / Codex), OpenAI, Gemini, Ollama, or a custom OpenAI-compatible endpoint in Settings.');
372
375
  }
373
376
  let governedAnswer;
374
377
  let providerError;
@@ -413,7 +416,7 @@ export async function startLocalServer(opts) {
413
416
  }
414
417
  return governedAnswer;
415
418
  }
416
- const answerRunExecutor = async ({ request, routeDecision, attempt, repairHint }) => {
419
+ const answerRunExecutor = async ({ request, routeDecision, attempt, repairHint, emitAnswerDelta }) => {
417
420
  let governedAnswer;
418
421
  try {
419
422
  governedAnswer = await runGovernedAgentAnswerForRun(request, { attempt, repairHint });
@@ -443,6 +446,37 @@ export async function startLocalServer(opts) {
443
446
  const isCertified = governedAnswer.certification === 'certified' || governedAnswer.kind === 'certified';
444
447
  const needsClarification = governedAnswer.kind === 'no_answer';
445
448
  const sql = governedAnswer.proposedSql ?? governedAnswer.sql;
449
+ // Synthesis: for a genuinely generated (non-certified) answer, compose an
450
+ // adaptive, well-formatted reply over the executed rows — streamed — instead of
451
+ // the loop's raw draft. Certified answers keep their fast path (no extra call).
452
+ let synthesizedAnswer;
453
+ if (!needsClarification && !isCertified) {
454
+ try {
455
+ const provider = await createBlockStudioAssistProvider(projectRoot);
456
+ if (provider) {
457
+ const preview = agentResultToSynthesisPreview(governedAnswer.result);
458
+ const draft = governedAnswer.answer ?? governedAnswer.text;
459
+ const result = await synthesizeAnswer({
460
+ question: request.question,
461
+ category: routeDecision?.category,
462
+ audience: request.audience ?? 'analyst',
463
+ resultPreview: preview,
464
+ sql: sql,
465
+ draftText: draft,
466
+ gaps: governedAnswer.validationWarnings,
467
+ }, {
468
+ onDelta: emitAnswerDelta,
469
+ complete: ({ system, user, signal, onDelta }) => streamOrGenerate(provider, [{ role: 'system', content: system }, { role: 'user', content: user }], { maxTokens: 350, temperature: 0.3, signal }, onDelta ?? (() => { })),
470
+ });
471
+ if (result.text)
472
+ synthesizedAnswer = result.text;
473
+ }
474
+ }
475
+ catch {
476
+ // Keep the governed draft on any synthesis failure.
477
+ synthesizedAnswer = undefined;
478
+ }
479
+ }
446
480
  const status = needsClarification ? 'needs_clarification' : isCertified ? 'completed' : 'needs_review';
447
481
  const trustState = needsClarification ? 'not_applicable' : isCertified ? 'certified' : 'review_required';
448
482
  const stopReason = needsClarification ? 'needs_clarification' : isCertified ? 'certified_answer_found' : 'human_review_required';
@@ -455,7 +489,7 @@ export async function startLocalServer(opts) {
455
489
  ];
456
490
  return {
457
491
  summary: governedAnswer.route?.label ?? (isCertified ? 'Answered from certified DQL context.' : 'Answered with review-required generated analysis.'),
458
- answer: governedAnswer.answer ?? governedAnswer.text,
492
+ answer: synthesizedAnswer ?? governedAnswer.answer ?? governedAnswer.text,
459
493
  status,
460
494
  trustState,
461
495
  stopReason,
@@ -476,7 +510,54 @@ export async function startLocalServer(opts) {
476
510
  nextActions,
477
511
  };
478
512
  };
513
+ const conversationRunExecutor = async ({ request, routeDecision, emitAnswerDelta }) => {
514
+ const kind = routeDecision?.conversationalKind ?? 'smalltalk';
515
+ const isGeneralKnowledge = routeDecision?.category === 'general_knowledge';
516
+ const answerKind = isGeneralKnowledge ? 'general_knowledge' : 'conversational';
517
+ const catalogContext = buildAgentRunCatalogContext();
518
+ const suggestions = buildConversationSuggestions(projectRoot, kind);
519
+ const nextActions = suggestions.map((prompt, index) => ({
520
+ id: `suggest-question-${index + 1}`,
521
+ label: prompt,
522
+ }));
523
+ let text;
524
+ try {
525
+ const provider = await createBlockStudioAssistProvider(projectRoot);
526
+ if (provider) {
527
+ const system = buildConversationSystemPrompt(kind, isGeneralKnowledge, catalogContext, request.audience ?? 'analyst');
528
+ const messages = [
529
+ { role: 'system', content: system },
530
+ ...(request.history ?? []).slice(-6).map((message) => ({ role: message.role, content: message.text })),
531
+ { role: 'user', content: request.question },
532
+ ];
533
+ text = (await provider.generate(messages, { maxTokens: 320, temperature: 0.6 })).trim();
534
+ // Perceived-latency: surface the reply as one delta for surfaces wired to stream.
535
+ if (text)
536
+ emitAnswerDelta?.(text);
537
+ }
538
+ }
539
+ catch {
540
+ // Provider unavailable / errored — fall through to the deterministic reply.
541
+ text = undefined;
542
+ }
543
+ if (!text) {
544
+ text = buildConversationalFallback(kind, isGeneralKnowledge, request.question, suggestions);
545
+ emitAnswerDelta?.(text);
546
+ }
547
+ return {
548
+ summary: 'Replied conversationally.',
549
+ answer: text,
550
+ answerKind,
551
+ status: 'completed',
552
+ trustState: 'not_applicable',
553
+ stopReason: 'conversational_reply',
554
+ artifacts: [],
555
+ evaluations: [],
556
+ nextActions,
557
+ };
558
+ };
479
559
  const agentRunExecutors = {
560
+ conversation: conversationRunExecutor,
480
561
  certified_answer: answerRunExecutor,
481
562
  generated_answer: answerRunExecutor,
482
563
  research: async ({ runId, request, routeDecision, emit }) => {
@@ -800,12 +881,29 @@ export async function startLocalServer(opts) {
800
881
  },
801
882
  getCatalogContext: buildAgentRunCatalogContext,
802
883
  });
884
+ // Hybrid router: keep the deterministic decision when it is confident (certified
885
+ // fast paths + greetings stay 0-LLM); spend one cheap classification call only for
886
+ // the ambiguous middle so Auto reliably picks quick-answer vs deep research without
887
+ // the user clicking "Dig deeper". Same provider completion as the planner.
888
+ const agentRunRouter = createHybridRouter({
889
+ complete: async ({ system, user, signal }) => {
890
+ const provider = await createBlockStudioAssistProvider(projectRoot);
891
+ if (!provider)
892
+ throw new Error('No AI provider configured for routing.');
893
+ return provider.generate([
894
+ { role: 'system', content: system },
895
+ { role: 'user', content: user },
896
+ ], { maxTokens: 250, temperature: 0, signal });
897
+ },
898
+ getCatalogContext: () => buildAgentRunCatalogContext(),
899
+ });
803
900
  const agentRunStore = new FileAgentRunStore({ path: defaultAgentRunStorePath(projectRoot) });
804
901
  const agentRunEngine = new AgentRunEngine({
805
902
  store: agentRunStore,
806
903
  executors: agentRunExecutors,
807
904
  gates: defaultAgentRunGates,
808
905
  planner: agentRunPlanner,
906
+ router: agentRunRouter,
809
907
  });
810
908
  const runNotebookForApp = async (appId, notebookPath) => {
811
909
  const absPath = safeJoin(projectRoot, notebookPath);
@@ -987,10 +1085,11 @@ export async function startLocalServer(opts) {
987
1085
  }
988
1086
  };
989
1087
  const generateInvestigationSqlForApp = async (input) => {
990
- const resolvedProvider = resolveDefaultLLMProvider(projectRoot);
991
- const runner = resolvedProvider ? getLLMRunner(resolvedProvider) : null;
1088
+ const governed = resolveGovernedAnswerRunner(projectRoot);
1089
+ const resolvedProvider = governed?.provider ?? null;
1090
+ const runner = governed?.runner ?? null;
992
1091
  if (!resolvedProvider || !runner) {
993
- throw new Error('No AI provider is configured. Configure OpenAI, Gemini, Ollama, or a custom OpenAI-compatible endpoint in Settings.');
1092
+ throw new Error('No AI provider is configured. Configure a subscription (Claude Code / Codex), OpenAI, Gemini, Ollama, or a custom OpenAI-compatible endpoint in Settings.');
994
1093
  }
995
1094
  let governedAnswer;
996
1095
  let providerError;
@@ -1202,8 +1301,9 @@ export async function startLocalServer(opts) {
1202
1301
  let providerError;
1203
1302
  const generationWarnings = [];
1204
1303
  if (!generatedSql && !reviewedSql) {
1205
- const resolvedProvider = resolveDefaultLLMProvider(projectRoot);
1206
- const runner = resolvedProvider ? getLLMRunner(resolvedProvider) : null;
1304
+ const governedResearch = resolveGovernedAnswerRunner(projectRoot);
1305
+ const resolvedProvider = governedResearch?.provider ?? null;
1306
+ const runner = governedResearch?.runner ?? null;
1207
1307
  if (!resolvedProvider || !runner) {
1208
1308
  generationWarnings.push('No AI provider is configured. Metadata context was saved as a research plan; paste SQL or configure an AI provider to generate candidate SQL.');
1209
1309
  }
@@ -1954,6 +2054,11 @@ export async function startLocalServer(opts) {
1954
2054
  res.end(serializeJSON({ error: parsed.error ?? 'Invalid agent run request.' }));
1955
2055
  return;
1956
2056
  }
2057
+ // The UI does not send retrieval signals; probe the local catalog so the
2058
+ // router can short-circuit obvious governed matches without an LLM call.
2059
+ if (!parsed.request.signals && !parsed.request.requestedMode) {
2060
+ parsed.request.signals = computeAgentRunSignals(projectRoot, parsed.request.question);
2061
+ }
1957
2062
  const wantsStream = url.searchParams.get('stream') === '1' || url.searchParams.get('stream') === 'true';
1958
2063
  if (wantsStream) {
1959
2064
  res.writeHead(200, {
@@ -1964,6 +2069,8 @@ export async function startLocalServer(opts) {
1964
2069
  });
1965
2070
  const run = await agentRunEngine.run(parsed.request, (event) => {
1966
2071
  writeAgentRunSse(res, 'agent-run-event', event);
2072
+ }, (delta) => {
2073
+ writeAgentRunSse(res, 'agent-run-answer-delta', { runId: parsed.request.runId, delta });
1967
2074
  });
1968
2075
  writeAgentRunSse(res, 'agent-run-complete', run);
1969
2076
  res.end();
@@ -2473,6 +2580,24 @@ export async function startLocalServer(opts) {
2473
2580
  res.end(serializeJSON({ providers: listProviderSettings(projectRoot) }));
2474
2581
  return;
2475
2582
  }
2583
+ // Live detection for subscription-CLI providers: is the CLI installed and logged
2584
+ // in, and (for Claude) which account/plan. Lets Settings show real status instead
2585
+ // of a static hint. Spawns the CLIs, so it's a separate async call from the list.
2586
+ if (req.method === 'GET' && path === '/api/settings/providers/cli-status') {
2587
+ try {
2588
+ const [claudeCode, codex] = await Promise.all([
2589
+ ClaudeCodeCliProvider.detect(),
2590
+ CodexCliProvider.detect(),
2591
+ ]);
2592
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
2593
+ res.end(serializeJSON({ status: { 'claude-code': claudeCode, codex } }));
2594
+ }
2595
+ catch (error) {
2596
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
2597
+ res.end(serializeJSON({ status: {}, error: error instanceof Error ? error.message : String(error) }));
2598
+ }
2599
+ return;
2600
+ }
2476
2601
  if (req.method === 'POST' && path === '/api/settings/providers') {
2477
2602
  try {
2478
2603
  const body = await readJSON(req);
@@ -6508,6 +6633,7 @@ function isLLMProviderId(value) {
6508
6633
  return value === 'anthropic'
6509
6634
  || value === 'claude-agent-sdk'
6510
6635
  || value === 'claude-code'
6636
+ || value === 'codex'
6511
6637
  || value === 'openai'
6512
6638
  || value === 'gemini'
6513
6639
  || value === 'ollama'
@@ -6518,7 +6644,9 @@ export function resolveDefaultLLMProvider(projectRoot) {
6518
6644
  const activeProvider = getActiveProvider(projectRoot);
6519
6645
  if (activeProvider) {
6520
6646
  const active = settings.find((item) => item.id === activeProvider);
6521
- if (active?.enabled)
6647
+ // Only return it for the chat-cell runner path if it's a runner provider id
6648
+ // (subscription-only ids like `codex` aren't runners; they fall through).
6649
+ if (active?.enabled && isLLMProviderId(activeProvider))
6522
6650
  return activeProvider;
6523
6651
  }
6524
6652
  const preferred = ['openai', 'gemini', 'anthropic', 'custom-openai', 'ollama'];
@@ -6529,6 +6657,23 @@ export function resolveDefaultLLMProvider(projectRoot) {
6529
6657
  }
6530
6658
  return null;
6531
6659
  }
6660
+ /**
6661
+ * Resolve the runner that produces a GOVERNED answer (answer-loop with a completion
6662
+ * provider). Subscription CLI providers (Claude Code / Codex) are forced through the
6663
+ * answer-loop runner — never the MCP `claudeCodeRunner`, which doesn't emit a governed
6664
+ * answer envelope. Everything else uses the Settings-resolved default runner.
6665
+ */
6666
+ function resolveGovernedAnswerRunner(projectRoot) {
6667
+ const active = getActiveProvider(projectRoot);
6668
+ if (active === 'claude-code' || active === 'codex') {
6669
+ return { provider: active, runner: createDqlAgentProviderRunner(active) };
6670
+ }
6671
+ const resolved = resolveDefaultLLMProvider(projectRoot);
6672
+ if (!resolved)
6673
+ return null;
6674
+ const runner = getLLMRunner(resolved);
6675
+ return runner ? { provider: resolved, runner } : null;
6676
+ }
6532
6677
  function loadAppDashboard(projectRoot, appId, dashboardId) {
6533
6678
  for (const p of findAppDocuments(projectRoot)) {
6534
6679
  const { document: app } = loadAppDocument(p);
@@ -9913,10 +10058,13 @@ async function buildBlockStudioAiAssistSummary(projectRoot, action, candidate, v
9913
10058
  async function createBlockStudioAssistProvider(projectRoot, requestedProvider) {
9914
10059
  const settings = listProviderSettings(projectRoot);
9915
10060
  const activeProvider = getActiveProvider(projectRoot);
10061
+ // Subscription CLI providers (Claude Code / Codex) carry no API key — they're
10062
+ // usable when enabled; their real "installed + logged in" check runs in available().
10063
+ const isUsable = (provider) => provider.enabled && (provider.hasApiKey || provider.authMode === 'subscription_cli');
9916
10064
  const selected = requestedProvider
9917
- ? settings.find((provider) => provider.id === requestedProvider && provider.enabled && provider.hasApiKey)
10065
+ ? settings.find((provider) => provider.id === requestedProvider && isUsable(provider))
9918
10066
  : settings.find((provider) => provider.id === activeProvider && provider.enabled)
9919
- ?? settings.find((provider) => provider.enabled && provider.hasApiKey);
10067
+ ?? settings.find(isUsable);
9920
10068
  if (!selected)
9921
10069
  return null;
9922
10070
  const config = getEffectiveProviderConfig(projectRoot, selected.id);
@@ -9937,11 +10085,171 @@ async function createBlockStudioAssistProvider(projectRoot, requestedProvider) {
9937
10085
  case 'custom-openai':
9938
10086
  provider = new OpenAIProvider({ apiKey: config.apiKey, baseUrl: config.baseUrl, model: config.model, allowNoApiKey: true });
9939
10087
  break;
10088
+ case 'claude-code':
10089
+ provider = new ClaudeCodeCliProvider({ model: config.model });
10090
+ break;
10091
+ case 'codex':
10092
+ provider = new CodexCliProvider({ model: config.model });
10093
+ break;
9940
10094
  default:
9941
10095
  return null;
9942
10096
  }
9943
10097
  return await provider.available() ? provider : null;
9944
10098
  }
10099
+ /** Convert a governed answer's result payload into a bounded synthesis preview. */
10100
+ function agentResultToSynthesisPreview(result) {
10101
+ if (!result)
10102
+ return undefined;
10103
+ const columns = Array.isArray(result.columns)
10104
+ ? result.columns.map((col) => typeof col === 'string' ? col : col?.name ?? String(col))
10105
+ : [];
10106
+ const rawRows = Array.isArray(result.rows) ? result.rows.slice(0, 20) : [];
10107
+ const rows = rawRows.map((row) => {
10108
+ if (row && typeof row === 'object' && !Array.isArray(row))
10109
+ return row;
10110
+ // Array-shaped rows → zip with column names.
10111
+ if (Array.isArray(row)) {
10112
+ const obj = {};
10113
+ row.forEach((value, index) => { obj[columns[index] ?? `col${index}`] = value; });
10114
+ return obj;
10115
+ }
10116
+ return { value: row };
10117
+ });
10118
+ return {
10119
+ columns: columns.length > 0 ? columns : Object.keys(rows[0] ?? {}),
10120
+ rows,
10121
+ rowCount: typeof result.rowCount === 'number' ? result.rowCount : rows.length,
10122
+ };
10123
+ }
10124
+ const SIGNAL_STOPWORDS = new Set([
10125
+ 'the', 'a', 'an', 'of', 'for', 'to', 'by', 'in', 'on', 'and', 'or', 'is', 'are', 'was', 'were',
10126
+ 'what', 'which', 'how', 'show', 'me', 'my', 'our', 'this', 'that', 'top', 'give', 'get', 'list',
10127
+ ]);
10128
+ function signalTokens(text) {
10129
+ return new Set(text.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').split(/\s+/)
10130
+ .filter((token) => token.length > 2 && !SIGNAL_STOPWORDS.has(token)));
10131
+ }
10132
+ /** Best token-overlap ratio between the question and any candidate name/description. */
10133
+ function bestOverlap(questionTokens, candidates) {
10134
+ if (questionTokens.size === 0)
10135
+ return 0;
10136
+ let best = 0;
10137
+ for (const candidate of candidates) {
10138
+ const candTokens = signalTokens(candidate);
10139
+ if (candTokens.size === 0)
10140
+ continue;
10141
+ let hits = 0;
10142
+ for (const token of candTokens)
10143
+ if (questionTokens.has(token))
10144
+ hits += 1;
10145
+ if (hits === 0)
10146
+ continue;
10147
+ // Ratio of the candidate's tokens the question covers — rewards a tight match.
10148
+ const ratio = hits / Math.min(candTokens.size, questionTokens.size);
10149
+ if (ratio > best)
10150
+ best = ratio;
10151
+ }
10152
+ return Math.min(1, best);
10153
+ }
10154
+ /**
10155
+ * Cheap, offline retrieval-signal probe. Fills certifiedScore/metricScore/hasRetrieval
10156
+ * from local catalog name/description overlap so the deterministic router can reach
10157
+ * confidence (and skip the LLM) on obvious governed matches. Best-effort — any failure
10158
+ * yields empty signals and the router simply leans on its LLM classification.
10159
+ */
10160
+ function computeAgentRunSignals(projectRoot, question) {
10161
+ try {
10162
+ const tokens = signalTokens(question);
10163
+ if (tokens.size === 0)
10164
+ return { certifiedScore: 0, metricScore: 0, hasRetrieval: false };
10165
+ const certifiedBlocks = collectPlanBlocks(projectRoot, { certifiedOnly: true });
10166
+ const blockStrings = certifiedBlocks.flatMap((block) => [block.name, block.description ?? ''].filter(Boolean));
10167
+ const metrics = loadSemanticMetrics(projectRoot);
10168
+ const metricStrings = metrics.map((metric) => {
10169
+ const node = metric;
10170
+ return node.name ?? node.label ?? node.id ?? '';
10171
+ }).filter(Boolean);
10172
+ const certifiedScore = bestOverlap(tokens, blockStrings);
10173
+ const metricScore = bestOverlap(tokens, metricStrings);
10174
+ return { certifiedScore, metricScore, hasRetrieval: certifiedScore > 0 || metricScore > 0 };
10175
+ }
10176
+ catch {
10177
+ return { certifiedScore: 0, metricScore: 0, hasRetrieval: false };
10178
+ }
10179
+ }
10180
+ /** Up to three example questions built from real catalog blocks (falls back to generic asks). */
10181
+ function buildConversationSuggestions(projectRoot, kind) {
10182
+ if (kind === 'gratitude')
10183
+ return [];
10184
+ let names = [];
10185
+ try {
10186
+ let blocks = collectPlanBlocks(projectRoot, { certifiedOnly: true });
10187
+ if (blocks.length === 0)
10188
+ blocks = collectPlanBlocks(projectRoot, { certifiedOnly: false });
10189
+ names = blocks.slice(0, 3).map((block) => block.name).filter(Boolean);
10190
+ }
10191
+ catch {
10192
+ names = [];
10193
+ }
10194
+ if (names.length === 0) {
10195
+ return [
10196
+ 'What is total revenue?',
10197
+ 'Top customers by revenue this quarter',
10198
+ 'Why is revenue down by region?',
10199
+ ];
10200
+ }
10201
+ const humanize = (name) => name.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
10202
+ return names.map((name) => `Show me ${humanize(name)}`);
10203
+ }
10204
+ /** The system prompt for a conversational reply — persona + capability card + catalog. */
10205
+ function buildConversationSystemPrompt(kind, isGeneralKnowledge, catalogContext, audience) {
10206
+ const capabilities = audience === 'stakeholder'
10207
+ ? [
10208
+ 'answer questions about their data, grounded in certified metrics and dbt lineage',
10209
+ 'run a deeper investigation when a question needs it ("dig deeper")',
10210
+ ]
10211
+ : [
10212
+ 'answer questions about their data, grounded in certified metrics and dbt lineage',
10213
+ 'run a deeper multi-step investigation ("dig deeper")',
10214
+ 'draft SQL cells and DQL blocks for review',
10215
+ 'build dashboards and apps from certified blocks',
10216
+ ];
10217
+ const lines = [
10218
+ 'You are DQL\'s assistant inside a governed analytics notebook. Be warm, concise, and helpful.',
10219
+ `You can: ${capabilities.map((c) => `(${c})`).join(', ')}.`,
10220
+ 'Reply in at most 2-3 short sentences. Do NOT invent data values, metrics, or numbers.',
10221
+ 'If the user seems to want data, invite them to ask — their question will run through the governed loop.',
10222
+ ];
10223
+ if (isGeneralKnowledge) {
10224
+ lines.push('This is a general-knowledge question. Answer from your own knowledge, plainly and briefly.', 'Do NOT imply the answer comes from the user\'s data or warehouse. If it relates to their work, offer to look at their governed data next.');
10225
+ }
10226
+ else if (kind === 'meta_capability') {
10227
+ lines.push('The user is asking what you can do — briefly describe your capabilities and suggest a concrete first question.');
10228
+ }
10229
+ if (catalogContext) {
10230
+ lines.push('', 'When suggesting example questions, prefer these real objects in their workspace:', catalogContext);
10231
+ }
10232
+ return lines.join('\n');
10233
+ }
10234
+ /** Deterministic, warm reply when no AI provider is configured — never governance-speak. */
10235
+ function buildConversationalFallback(kind, isGeneralKnowledge, question, suggestions) {
10236
+ const examples = suggestions.length > 0
10237
+ ? ` For example: ${suggestions.map((s) => `"${s}"`).join(', ')}.`
10238
+ : '';
10239
+ if (isGeneralKnowledge) {
10240
+ return `That's a general-knowledge question, so I can't answer it from your governed data. Ask me about your metrics or dbt models and I'll ground the answer in your workspace.${examples}`;
10241
+ }
10242
+ switch (kind) {
10243
+ case 'greeting':
10244
+ return `Hi! I'm your DQL assistant — I answer questions about your data, grounded in your certified metrics and dbt lineage.${examples}`;
10245
+ case 'gratitude':
10246
+ return "You're welcome! Ask me anything else about your data whenever you're ready.";
10247
+ case 'meta_capability':
10248
+ return `I can answer questions about your data (grounded in certified metrics and dbt lineage), dig deeper with a multi-step investigation, draft SQL and DQL blocks, and build apps from certified blocks.${examples}`;
10249
+ default:
10250
+ return `I'm here to help you explore your data. Ask me a question and I'll ground the answer in your certified metrics and dbt models.${examples}`;
10251
+ }
10252
+ }
9945
10253
  function buildDeterministicAiAssistSummary(action, candidate, validation) {
9946
10254
  const tables = candidate.lineage.sourceTables.length > 0 ? candidate.lineage.sourceTables.join(', ') : 'no source tables detected';
9947
10255
  const params = candidate.lineage.parameters.length > 0 ? candidate.lineage.parameters.join(', ') : 'no parameters detected';
@@ -11713,7 +12021,10 @@ async function testProviderConfig(projectRoot, id, overrides) {
11713
12021
  };
11714
12022
  const label = providerSettingsLabel(id);
11715
12023
  const details = providerConfigDetails(id, config);
11716
- if (!config.enabled) {
12024
+ const isSubscriptionCli = id === 'claude-code' || id === 'codex';
12025
+ // Subscription CLI providers are always testable — detection (installed + logged in)
12026
+ // needs no saved config; the others require enabling/saving first.
12027
+ if (!config.enabled && !isSubscriptionCli) {
11717
12028
  return { ok: false, message: `${label} is disabled. Enable it (or Save) before testing.` };
11718
12029
  }
11719
12030
  if (id === 'openai')
@@ -11728,6 +12039,12 @@ async function testProviderConfig(projectRoot, id, overrides) {
11728
12039
  case 'ollama':
11729
12040
  provider = new OllamaProvider({ baseUrl: config.baseUrl, model: config.model });
11730
12041
  break;
12042
+ case 'claude-code':
12043
+ provider = new ClaudeCodeCliProvider({ model: config.model });
12044
+ break;
12045
+ case 'codex':
12046
+ provider = new CodexCliProvider({ model: config.model });
12047
+ break;
11731
12048
  case 'custom-openai':
11732
12049
  default:
11733
12050
  provider = new OpenAIProvider({ apiKey: config.apiKey, baseUrl: config.baseUrl, model: config.model, allowNoApiKey: true });
@@ -11735,9 +12052,12 @@ async function testProviderConfig(projectRoot, id, overrides) {
11735
12052
  }
11736
12053
  const available = await provider.available().catch(() => false);
11737
12054
  if (!available) {
12055
+ const cli = id === 'claude-code' ? 'claude' : 'codex';
11738
12056
  return {
11739
12057
  ok: false,
11740
- message: `${label} is not configured or reachable${details}. Check API key, base URL, and local service state.`,
12058
+ message: isSubscriptionCli
12059
+ ? `${label} is not ready. Install the \`${cli}\` CLI and run \`${cli} ${id === 'claude-code' ? '/login' : 'login'}\` with your subscription, then test again.`
12060
+ : `${label} is not configured or reachable${details}. Check API key, base URL, and local service state.`,
11741
12061
  };
11742
12062
  }
11743
12063
  try {
@@ -11814,6 +12134,8 @@ function providerSettingsLabel(id) {
11814
12134
  case 'gemini': return 'Gemini';
11815
12135
  case 'ollama': return 'Ollama';
11816
12136
  case 'custom-openai': return 'Custom OpenAI-compatible provider';
12137
+ case 'claude-code': return 'Claude subscription (Claude Code CLI)';
12138
+ case 'codex': return 'ChatGPT subscription (Codex CLI)';
11817
12139
  }
11818
12140
  }
11819
12141
  function providerConfigDetails(id, config) {