gnosys 5.1.0 → 5.2.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/dist/lib/setup.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * Gnosys Interactive Setup Wizard.
3
3
  *
4
4
  * Guides users through provider selection, model tier, API key storage,
5
- * IDE integration. Web knowledge base is set up separately via: gnosys web init
5
+ * task model configuration, and IDE integration.
6
+ * Web knowledge base is set up separately via: gnosys web init
6
7
  *
7
8
  * Uses Node.js built-in readline/promises — no external dependencies.
8
9
  */
@@ -13,6 +14,7 @@ import fsSync from "fs";
13
14
  import path from "path";
14
15
  import os from "os";
15
16
  import { execSync } from "child_process";
17
+ import { loadConfig, updateConfig, getProviderModel, } from "./config.js";
16
18
  // ─── ANSI Colors ────────────────────────────────────────────────────────────
17
19
  const BOLD = "\x1b[1m";
18
20
  const DIM = "\x1b[2m";
@@ -275,6 +277,14 @@ const PROVIDER_ORDER = [
275
277
  "lmstudio",
276
278
  "custom",
277
279
  ];
280
+ // Task descriptions for display
281
+ const TASK_DESCRIPTIONS = {
282
+ structuring: "adding memories, tagging",
283
+ synthesis: "Q&A answers",
284
+ vision: "images, PDFs",
285
+ transcription: "audio files",
286
+ dream: "overnight consolidation",
287
+ };
278
288
  // ─── Exported Helpers ───────────────────────────────────────────────────────
279
289
  /**
280
290
  * Returns the cheapest capable model for structuring tasks.
@@ -329,6 +339,89 @@ export async function writeApiKey(provider, key) {
329
339
  }
330
340
  await fs.writeFile(envPath, lines.join("\n") + "\n", "utf-8");
331
341
  }
342
+ /**
343
+ * Write an API key to the macOS Keychain.
344
+ * Uses the -U flag to update if the entry already exists.
345
+ * Returns true on success, false on failure.
346
+ */
347
+ export function writeApiKeyToKeychain(envVar, key) {
348
+ if (process.platform !== "darwin")
349
+ return false;
350
+ try {
351
+ // The -U flag updates if the password already exists
352
+ execSync(`security add-generic-password -a "$USER" -s "${envVar}" -w "${key.replace(/"/g, '\\"')}" -U`, { stdio: "pipe" });
353
+ return true;
354
+ }
355
+ catch {
356
+ return false;
357
+ }
358
+ }
359
+ /**
360
+ * Write an API key using Linux secret-tool (GNOME Keyring).
361
+ * Returns true on success, false on failure.
362
+ */
363
+ function writeApiKeyToSecretTool(envVar, key, provider) {
364
+ if (process.platform === "darwin")
365
+ return false;
366
+ try {
367
+ // Check if secret-tool is available
368
+ execSync("which secret-tool", { stdio: "pipe" });
369
+ // Write the key — printf avoids trailing newline issues
370
+ execSync(`printf "%s" "${key.replace(/"/g, '\\"')}" | secret-tool store --label="Gnosys ${provider}" service gnosys account ${envVar}`, { stdio: "pipe", shell: "/bin/sh" });
371
+ return true;
372
+ }
373
+ catch {
374
+ return false;
375
+ }
376
+ }
377
+ /**
378
+ * Check if secret-tool is available on Linux.
379
+ */
380
+ function hasSecretTool() {
381
+ if (process.platform === "darwin")
382
+ return false;
383
+ try {
384
+ execSync("which secret-tool", { stdio: "pipe" });
385
+ return true;
386
+ }
387
+ catch {
388
+ return false;
389
+ }
390
+ }
391
+ /**
392
+ * Detect where an existing API key is stored.
393
+ * Returns description like "macOS Keychain", "env var", ".env file", or empty string.
394
+ */
395
+ function detectKeySource(envVarName, legacyEnvVar) {
396
+ // Check macOS Keychain
397
+ if (process.platform === "darwin") {
398
+ try {
399
+ const result = execSync(`security find-generic-password -a "$USER" -s "${envVarName}" -w`, { stdio: "pipe", encoding: "utf-8" }).trim();
400
+ if (result)
401
+ return "macOS Keychain";
402
+ }
403
+ catch {
404
+ // Not in keychain
405
+ }
406
+ }
407
+ // Check environment variable (new-style)
408
+ if (process.env[envVarName])
409
+ return `$${envVarName}`;
410
+ // Check legacy env var
411
+ if (legacyEnvVar && process.env[legacyEnvVar])
412
+ return `$${legacyEnvVar}`;
413
+ // Check .env file
414
+ try {
415
+ const envPath = path.join(os.homedir(), ".config", "gnosys", ".env");
416
+ const content = fsSync.readFileSync(envPath, "utf-8");
417
+ if (content.includes(`${envVarName}=`))
418
+ return "~/.config/gnosys/.env";
419
+ }
420
+ catch {
421
+ // No .env
422
+ }
423
+ return "";
424
+ }
332
425
  /**
333
426
  * Detect which IDEs are available in the given project directory.
334
427
  * Returns an array like ["claude", "cursor", "codex"].
@@ -440,7 +533,8 @@ export async function setupIDE(ide, projectDir) {
440
533
  */
441
534
  async function askChoice(rl, question, options) {
442
535
  console.log();
443
- console.log(question);
536
+ if (question)
537
+ console.log(question);
444
538
  console.log();
445
539
  for (let i = 0; i < options.length; i++) {
446
540
  console.log(` ${BOLD}${i + 1}.${RESET} ${options[i]}`);
@@ -485,6 +579,7 @@ function formatPrice(input, output) {
485
579
  }
486
580
  /**
487
581
  * Print a bordered box with a title and key-value rows.
582
+ * Supports rows with empty keys (spacer rows) and section headers.
488
583
  */
489
584
  function printBox(title, rows) {
490
585
  const maxKeyLen = Math.max(...rows.map(([k]) => k.length));
@@ -497,8 +592,14 @@ function printBox(title, rows) {
497
592
  console.log(`\u2502 ${BOLD}${title}${RESET}${" ".repeat(innerWidth - title.length - 2)}\u2502`);
498
593
  console.log(`\u251C${border}\u2524`);
499
594
  for (const [key, val] of rows) {
500
- const line = `${key.padEnd(maxKeyLen)} ${val}`;
501
- console.log(`\u2502 ${line}${" ".repeat(innerWidth - line.length - 2)}\u2502`);
595
+ if (key === "" && val === "") {
596
+ // Spacer row
597
+ console.log(`\u2502${" ".repeat(innerWidth)}\u2502`);
598
+ }
599
+ else {
600
+ const line = `${key.padEnd(maxKeyLen)} ${val}`;
601
+ console.log(`\u2502 ${line}${" ".repeat(innerWidth - line.length - 2)}\u2502`);
602
+ }
502
603
  }
503
604
  console.log(`\u2514${border}\u2518`);
504
605
  console.log();
@@ -535,6 +636,77 @@ async function getRegisteredProjects() {
535
636
  }
536
637
  return projects;
537
638
  }
639
+ /**
640
+ * Try to load existing gnosys.json config for displaying current values.
641
+ * Checks the project .gnosys dir first, then the global ~/.gnosys dir.
642
+ * Returns null if no config found.
643
+ */
644
+ async function loadExistingConfig(projectDir) {
645
+ // Try project-level config first
646
+ try {
647
+ const projectStore = path.join(projectDir, ".gnosys");
648
+ const stat = await fs.stat(path.join(projectStore, "gnosys.json"));
649
+ if (stat.isFile()) {
650
+ return await loadConfig(projectStore);
651
+ }
652
+ }
653
+ catch {
654
+ // No project config
655
+ }
656
+ // Try global config at ~/.gnosys
657
+ try {
658
+ const globalStore = path.join(os.homedir(), ".gnosys");
659
+ const stat = await fs.stat(path.join(globalStore, "gnosys.json"));
660
+ if (stat.isFile()) {
661
+ return await loadConfig(globalStore);
662
+ }
663
+ }
664
+ catch {
665
+ // No global config
666
+ }
667
+ return null;
668
+ }
669
+ /**
670
+ * Let the user pick a provider from the list.
671
+ * Returns the provider name or "skip".
672
+ * If currentProvider is given, shows it as the current value.
673
+ */
674
+ async function pickProvider(rl, dynamicModels, stepLabel, currentProvider) {
675
+ const currentHint = currentProvider ? ` ${DIM}(current: ${currentProvider})${RESET}` : "";
676
+ const providerOptions = PROVIDER_ORDER.map((key) => {
677
+ const tiers = dynamicModels[key] ?? PROVIDER_TIERS[key];
678
+ const display = PROVIDER_DISPLAY[key];
679
+ if (!tiers || tiers.length === 0)
680
+ return display;
681
+ const minIn = Math.min(...tiers.map((t) => t.input));
682
+ const maxOut = Math.max(...tiers.map((t) => t.output));
683
+ if (minIn === 0 && maxOut === 0)
684
+ return display;
685
+ return `${display} ${DIM}$${minIn.toFixed(2)}\u2013$${maxOut.toFixed(2)}/M tokens${RESET}`;
686
+ });
687
+ const choiceIdx = await askChoice(rl, `${stepLabel}${currentHint}`, providerOptions);
688
+ return PROVIDER_ORDER[choiceIdx];
689
+ }
690
+ /**
691
+ * Let the user pick a model from a provider's tiers.
692
+ * Returns the model string.
693
+ */
694
+ async function pickModel(rl, provider, dynamicModels, stepLabel, currentModel) {
695
+ const tiers = dynamicModels[provider] ?? PROVIDER_TIERS[provider];
696
+ if (!tiers || tiers.length === 0)
697
+ return "";
698
+ const isLocal = provider === "ollama" || provider === "lmstudio";
699
+ const currentHint = currentModel ? ` ${DIM}(current: ${currentModel})${RESET}` : "";
700
+ const tierOptions = tiers.map((t) => {
701
+ const rec = t.recommended ? ` ${CYAN}<- recommended${RESET}` : "";
702
+ if (isLocal) {
703
+ return `${t.name}${rec}`;
704
+ }
705
+ return `${t.name} (${t.model}) ${DIM}${formatPrice(t.input, t.output)}${RESET}${rec}`;
706
+ });
707
+ const tierIndex = await askChoice(rl, `${stepLabel}${currentHint}`, tierOptions);
708
+ return tiers[tierIndex].model;
709
+ }
538
710
  // ─── Main Setup Wizard ──────────────────────────────────────────────────────
539
711
  export async function runSetup(opts) {
540
712
  const version = getVersion();
@@ -585,6 +757,12 @@ export async function runSetup(opts) {
585
757
  console.log(`\u2502 ${DIM}${tagline}${RESET}${" ".repeat(bannerInner - tagline.length - 2)}\u2502`);
586
758
  console.log(`\u2514${bannerBorder}\u2518`);
587
759
  console.log();
760
+ // ─── Load existing config for defaults ───────────────────────────
761
+ const existingConfig = await loadExistingConfig(projectDir);
762
+ const currentProvider = existingConfig?.llm.defaultProvider;
763
+ const currentModel = existingConfig
764
+ ? getProviderModel(existingConfig, existingConfig.llm.defaultProvider)
765
+ : undefined;
588
766
  // ─── Pre-check: Upgrade detection ─────────────────────────────────
589
767
  const centralDbPath = path.join(os.homedir(), ".gnosys", "gnosys.db");
590
768
  const centralDbExists = fsSync.existsSync(centralDbPath);
@@ -643,7 +821,7 @@ export async function runSetup(opts) {
643
821
  console.log(`${DIM}Using bundled model data (offline or fetch failed)${RESET}`);
644
822
  }
645
823
  console.log();
646
- // ─── Step 1/4 — Provider ──────────────────────────────────────────
824
+ // ─── Step 1/5 — Provider ──────────────────────────────────────────
647
825
  const providerOptions = PROVIDER_ORDER.map((key) => {
648
826
  const tiers = dynamicModels[key] ?? PROVIDER_TIERS[key];
649
827
  const display = PROVIDER_DISPLAY[key];
@@ -657,15 +835,21 @@ export async function runSetup(opts) {
657
835
  });
658
836
  // Add "Skip" option
659
837
  providerOptions.push("Skip (core memory works without LLM)");
660
- const providerIndex = await askChoice(rl, `${BOLD}Step 1/4${RESET} ${DIM}\u2014${RESET} Choose your LLM provider`, providerOptions);
838
+ const currentProviderHint = currentProvider
839
+ ? ` ${DIM}(current: ${currentProvider})${RESET}`
840
+ : "";
841
+ const providerIndex = await askChoice(rl, `${BOLD}Step 1/5${RESET} ${DIM}\u2014${RESET} Choose your LLM provider${currentProviderHint}`, providerOptions);
661
842
  const isSkip = providerIndex === PROVIDER_ORDER.length; // last option
662
843
  const provider = isSkip ? "skip" : PROVIDER_ORDER[providerIndex];
663
- // ─── Step 2/4 — Model tier ────────────────────────────────────────
844
+ // ─── Step 2/5 — Model tier ────────────────────────────────────────
664
845
  let model = "";
665
846
  if (!isSkip && provider !== "custom") {
666
847
  const tiers = dynamicModels[provider] ?? PROVIDER_TIERS[provider];
667
848
  if (tiers.length > 0) {
668
849
  const isLocal = provider === "ollama" || provider === "lmstudio";
850
+ const currentModelHint = (currentProvider === provider && currentModel)
851
+ ? ` ${DIM}(current: ${currentModel})${RESET}`
852
+ : "";
669
853
  const tierOptions = tiers.map((t) => {
670
854
  const rec = t.recommended ? ` ${CYAN}<- recommended${RESET}` : "";
671
855
  if (isLocal) {
@@ -673,14 +857,14 @@ export async function runSetup(opts) {
673
857
  }
674
858
  return `${t.name} (${t.model}) ${DIM}${formatPrice(t.input, t.output)}${RESET}${rec}`;
675
859
  });
676
- const tierIndex = await askChoice(rl, `${BOLD}Step 2/4${RESET} ${DIM}\u2014${RESET} Choose model tier`, tierOptions);
860
+ const tierIndex = await askChoice(rl, `${BOLD}Step 2/5${RESET} ${DIM}\u2014${RESET} Choose model tier${currentModelHint}`, tierOptions);
677
861
  model = tiers[tierIndex].model;
678
862
  }
679
863
  }
680
864
  else if (provider === "custom") {
681
865
  // Custom: ask for base URL and model name
682
866
  console.log();
683
- console.log(`${BOLD}Step 2/4${RESET} ${DIM}\u2014${RESET} Custom provider details`);
867
+ console.log(`${BOLD}Step 2/5${RESET} ${DIM}\u2014${RESET} Custom provider details`);
684
868
  console.log();
685
869
  const baseUrl = await askInput(rl, "Base URL (OpenAI-compatible)");
686
870
  model = await askInput(rl, "Model name");
@@ -715,11 +899,11 @@ export async function runSetup(opts) {
715
899
  }
716
900
  }
717
901
  else if (isSkip) {
718
- // Skip step 3 entirely
902
+ // Skip step 2 entirely
719
903
  console.log();
720
- console.log(`${DIM}Step 2/4 \u2014 Model tier: skipped${RESET}`);
904
+ console.log(`${DIM}Step 2/5 \u2014 Model tier: skipped${RESET}`);
721
905
  }
722
- // ─── Step 3/4 — API key ───────────────────────────────────────────
906
+ // ─── Step 3/5 — API key ───────────────────────────────────────────
723
907
  let apiKeyWritten = false;
724
908
  let apiKeySource = "";
725
909
  const needsKey = !isSkip &&
@@ -739,57 +923,95 @@ export async function runSetup(opts) {
739
923
  const legacyEnvVar = legacyEnvVars[provider] ?? "";
740
924
  if (needsKey) {
741
925
  console.log();
742
- console.log(`${BOLD}Step 3/4${RESET} ${DIM}\u2014${RESET} API Key`);
926
+ console.log(`${BOLD}Step 3/5${RESET} ${DIM}\u2014${RESET} API Key`);
743
927
  console.log();
928
+ // Check where the key currently lives
929
+ const existingKeySource = detectKeySource(envVarName, legacyEnvVar);
744
930
  // Check if key already exists in environment
745
931
  const existingKey = process.env[envVarName] || (legacyEnvVar ? process.env[legacyEnvVar] : "");
746
- if (existingKey) {
747
- const source = process.env[envVarName] ? envVarName : legacyEnvVar;
748
- console.log(` ${CHECK} Found existing key in $${source} (${maskKey(existingKey)})`);
932
+ if (existingKey || existingKeySource) {
933
+ const source = existingKeySource || "env";
934
+ console.log(` ${CHECK} Found existing key (${source})`);
935
+ if (existingKey) {
936
+ console.log(` ${DIM} ${maskKey(existingKey)}${RESET}`);
937
+ }
749
938
  apiKeyWritten = true;
750
- apiKeySource = "env";
939
+ apiKeySource = existingKeySource || "env";
940
+ // Offer to change it
941
+ const changeKey = await askYesNo(rl, " Change key storage?", false);
942
+ if (!changeKey) {
943
+ // Keep existing — skip the rest of step 3
944
+ }
945
+ else {
946
+ // Fall through to key storage options below
947
+ apiKeyWritten = false;
948
+ apiKeySource = "";
949
+ }
751
950
  }
752
- else {
951
+ if (!apiKeyWritten) {
753
952
  console.log(` Provider: ${GREEN}${provider}${RESET}`);
754
953
  console.log(` Env var: ${GREEN}${envVarName}${RESET}`);
755
954
  console.log();
756
955
  const isMac = process.platform === "darwin";
956
+ const isLinux = process.platform === "linux";
957
+ const hasSecret = isLinux && hasSecretTool();
757
958
  const shell = path.basename(process.env.SHELL ?? "zsh");
758
959
  const profileFile = shell === "bash" ? "~/.bash_profile" : "~/.zshrc";
759
960
  const options = [];
760
961
  if (isMac) {
761
962
  options.push(`Store in macOS Keychain (recommended \u2014 most secure, no plaintext on disk)`);
762
963
  }
964
+ if (hasSecret) {
965
+ options.push(`Store in GNOME Keyring (recommended \u2014 encrypted, no plaintext on disk)`);
966
+ }
763
967
  options.push(`Set via environment variable (${profileFile})`, `Save to ~/.config/gnosys/.env (\u26a0 plaintext on disk \u2014 least secure)`, `Skip (configure later)`);
764
968
  const keyChoice = await askChoice(rl, "", options);
765
- // Adjust index based on whether macOS Keychain option was shown
766
- const keychainIdx = isMac ? 0 : -1;
767
- const envIdx = isMac ? 1 : 0;
768
- const dotenvIdx = isMac ? 2 : 1;
769
- const skipIdx = isMac ? 3 : 2;
969
+ // Build the index map based on which options are present
970
+ let idx = 0;
971
+ const keychainIdx = isMac ? idx++ : -1;
972
+ const gnomeIdx = hasSecret ? idx++ : -1;
973
+ const envIdx = idx++;
974
+ const dotenvIdx = idx++;
975
+ const skipIdx = idx++;
976
+ // skipIdx is unused as a variable but documents the last index
770
977
  if (keyChoice === keychainIdx) {
771
- // macOS Keychain
772
- console.log();
773
- console.log(` Run this in a ${BOLD}separate terminal${RESET}:`);
774
- console.log();
775
- console.log(` ${GREEN}security add-generic-password -a "$USER" -s "${envVarName}" -w "your-key-here"${RESET}`);
776
- console.log();
777
- console.log(` ${DIM}Replace "your-key-here" with your actual API key.${RESET}`);
778
- console.log(` ${DIM}Gnosys will read it automatically at runtime.${RESET}`);
978
+ // macOS Keychain — ask for the key directly and write it
779
979
  console.log();
780
- await askInput(rl, "Press Enter after setting the key...", { default: "" });
781
- // Verify the key was set
782
- try {
783
- const { execSync } = await import("child_process");
784
- const result = execSync(`security find-generic-password -a "$USER" -s "${envVarName}" -w`, { stdio: "pipe", encoding: "utf-8" }).trim();
785
- if (result) {
786
- console.log(` ${CHECK} Key verified in macOS Keychain (${maskKey(result)})`);
980
+ const key = await askInput(rl, `Enter your ${provider} API key`);
981
+ if (key) {
982
+ const success = writeApiKeyToKeychain(envVarName, key);
983
+ if (success) {
984
+ console.log(` ${CHECK} Key saved to macOS Keychain (${maskKey(key)})`);
787
985
  apiKeyWritten = true;
788
- apiKeySource = "keychain";
986
+ apiKeySource = "macOS Keychain";
987
+ }
988
+ else {
989
+ console.log(` ${CROSS} Failed to write to Keychain. Falling back to .env file.`);
990
+ await writeApiKey(provider, key);
991
+ console.log(` ${CHECK} Key saved to ~/.config/gnosys/.env (${maskKey(key)})`);
992
+ apiKeyWritten = true;
993
+ apiKeySource = "~/.config/gnosys/.env";
789
994
  }
790
995
  }
791
- catch {
792
- console.log(` ${WARN} Could not verify key in Keychain. You can set it later.`);
996
+ }
997
+ else if (keyChoice === gnomeIdx) {
998
+ // Linux GNOME Keyring — ask for the key directly
999
+ console.log();
1000
+ const key = await askInput(rl, `Enter your ${provider} API key`);
1001
+ if (key) {
1002
+ const success = writeApiKeyToSecretTool(envVarName, key, provider);
1003
+ if (success) {
1004
+ console.log(` ${CHECK} Key saved to GNOME Keyring (${maskKey(key)})`);
1005
+ apiKeyWritten = true;
1006
+ apiKeySource = "GNOME Keyring";
1007
+ }
1008
+ else {
1009
+ console.log(` ${CROSS} Failed to write to GNOME Keyring. Falling back to .env file.`);
1010
+ await writeApiKey(provider, key);
1011
+ console.log(` ${CHECK} Key saved to ~/.config/gnosys/.env (${maskKey(key)})`);
1012
+ apiKeyWritten = true;
1013
+ apiKeySource = "~/.config/gnosys/.env";
1014
+ }
793
1015
  }
794
1016
  }
795
1017
  else if (keyChoice === envIdx) {
@@ -804,10 +1026,9 @@ export async function runSetup(opts) {
804
1026
  console.log();
805
1027
  await askInput(rl, "Press Enter after setting the key...", { default: "" });
806
1028
  // Verify
807
- // Note: we can't detect it in this process since env was set in another terminal
808
1029
  console.log(` ${DIM}Key will be available in new terminal sessions.${RESET}`);
809
1030
  apiKeyWritten = true;
810
- apiKeySource = "env";
1031
+ apiKeySource = "shell profile";
811
1032
  }
812
1033
  else if (keyChoice === dotenvIdx) {
813
1034
  // .env file (least secure)
@@ -824,7 +1045,7 @@ export async function runSetup(opts) {
824
1045
  await writeApiKey(provider, key);
825
1046
  console.log(` ${CHECK} Key saved to ~/.config/gnosys/.env (${maskKey(key)})`);
826
1047
  apiKeyWritten = true;
827
- apiKeySource = "dotenv";
1048
+ apiKeySource = "~/.config/gnosys/.env";
828
1049
  }
829
1050
  }
830
1051
  else {
@@ -834,7 +1055,12 @@ export async function runSetup(opts) {
834
1055
  else {
835
1056
  // Skip
836
1057
  console.log(` ${DIM}Skipped. Set your key later using one of these methods:`);
837
- console.log(` \u2022 macOS Keychain: security add-generic-password -a "$USER" -s "${envVarName}" -w "key"${isMac ? "" : " (macOS only)"}`);
1058
+ if (isMac) {
1059
+ console.log(` \u2022 macOS Keychain: security add-generic-password -a "$USER" -s "${envVarName}" -w "key" -U`);
1060
+ }
1061
+ if (hasSecret) {
1062
+ console.log(` \u2022 GNOME Keyring: printf '%s' 'key' | secret-tool store --label="Gnosys ${provider}" service gnosys account ${envVarName}`);
1063
+ }
838
1064
  console.log(` \u2022 Shell profile: echo 'export ${envVarName}=key' >> ${profileFile}`);
839
1065
  console.log(` \u2022 Dotenv file: echo '${envVarName}=key' >> ~/.config/gnosys/.env${RESET}`);
840
1066
  }
@@ -842,84 +1068,335 @@ export async function runSetup(opts) {
842
1068
  }
843
1069
  else {
844
1070
  console.log();
845
- console.log(`${DIM}Step 3/4 \u2014 API key: not needed (local provider)${RESET}`);
1071
+ console.log(`${DIM}Step 3/5 \u2014 API key: not needed (local provider)${RESET}`);
846
1072
  }
847
- // ─── Step 4/4IDE integration ───────────────────────────────────
848
- const detectedIdes = await detectIDEs(projectDir);
849
- const configuredIdes = [];
850
- if (detectedIdes.length > 0) {
851
- const ideLabels = {
852
- claude: "Claude Code",
853
- cursor: "Cursor",
854
- codex: "Codex",
855
- };
856
- const detectedNames = detectedIdes.map((id) => ideLabels[id] ?? id).join(", ");
1073
+ // ─── Step 4/5Task Model Configuration ─────────────────────────
1074
+ const taskOverrides = {};
1075
+ let dreamEnabled = existingConfig?.dream?.enabled ?? false;
1076
+ let dreamProvider = existingConfig?.dream?.provider ?? "ollama";
1077
+ let dreamModel = existingConfig?.dream?.model ?? "";
1078
+ if (!isSkip) {
857
1079
  console.log();
858
- console.log(`${BOLD}Step 4/4${RESET} ${DIM}\u2014${RESET} IDE Integration`);
1080
+ console.log(`${BOLD}Step 4/5${RESET} ${DIM}\u2014${RESET} Task Routing`);
859
1081
  console.log();
860
- console.log(`Detected: ${GREEN}${detectedNames}${RESET}`);
861
- const ideOptions = [];
862
- for (const ide of detectedIdes) {
863
- ideOptions.push(`${ideLabels[ide] ?? ide} only`);
864
- }
865
- if (detectedIdes.length > 1) {
866
- ideOptions.push("All detected");
867
- }
868
- ideOptions.push("Skip");
869
- const ideIndex = await askChoice(rl, "", ideOptions);
870
- let idesToSetup = [];
871
- if (ideIndex < detectedIdes.length) {
872
- // Individual IDE selected
873
- idesToSetup = [detectedIdes[ideIndex]];
874
- }
875
- else if (detectedIdes.length > 1 && ideIndex === detectedIdes.length) {
876
- // "All detected"
877
- idesToSetup = [...detectedIdes];
878
- }
879
- // Last option is always "Skip"
880
- for (const ide of idesToSetup) {
881
- const result = await setupIDE(ide, projectDir);
882
- if (result.success) {
883
- console.log(` ${CHECK} ${result.message}`);
884
- configuredIdes.push(ide);
1082
+ console.log(`Gnosys uses different LLM models for different tasks. Each defaults to your`);
1083
+ console.log(`chosen provider, but you can override them individually.`);
1084
+ console.log();
1085
+ const tasks = ["structuring", "synthesis", "vision", "transcription"];
1086
+ // Build a temporary config to see what defaults look like with the new provider
1087
+ const effectiveRouting = {};
1088
+ for (const task of tasks) {
1089
+ if (existingConfig?.taskModels?.[task]) {
1090
+ // Use the existing override
1091
+ effectiveRouting[task] = {
1092
+ provider: existingConfig.taskModels[task].provider,
1093
+ model: existingConfig.taskModels[task].model,
1094
+ };
885
1095
  }
886
1096
  else {
887
- console.log(` ${CROSS} ${ideLabels[ide] ?? ide}: ${result.message}`);
1097
+ // Derive from the newly chosen default provider
1098
+ const p = provider;
1099
+ let m = model;
1100
+ if (task === "structuring") {
1101
+ m = getStructuringModel(p, model);
1102
+ }
1103
+ effectiveRouting[task] = { provider: p, model: m };
888
1104
  }
889
1105
  }
890
- // Sync global rules
891
- if (idesToSetup.length > 0) {
892
- try {
893
- const { syncToTarget } = await import("./rulesGen.js");
894
- const { GnosysDB } = await import("./db.js");
895
- const db = GnosysDB.openCentral();
896
- await syncToTarget(db, projectDir, "global", null);
897
- db.close();
1106
+ // Dream routing
1107
+ effectiveRouting.dream = {
1108
+ provider: dreamProvider,
1109
+ model: dreamModel || getProviderModel(existingConfig ?? { llm: { defaultProvider: "ollama", ollama: { model: "llama3.2", baseUrl: "http://localhost:11434" } } }, dreamProvider),
1110
+ };
1111
+ // Display the table
1112
+ const taskNameWidth = 16;
1113
+ const routingWidth = 38;
1114
+ console.log(` ${BOLD}${"Task".padEnd(taskNameWidth)}${"Current Routing".padEnd(routingWidth)}${RESET}`);
1115
+ console.log(` ${"\u2500".repeat(taskNameWidth + routingWidth)}`);
1116
+ for (const task of [...tasks, "dream"]) {
1117
+ const r = effectiveRouting[task];
1118
+ const desc = TASK_DESCRIPTIONS[task] ?? "";
1119
+ const routingStr = `${r.provider} / ${r.model}`;
1120
+ const status = task === "dream" && !dreamEnabled ? `${DIM}(disabled)${RESET}` : `${DIM}(${desc})${RESET}`;
1121
+ console.log(` ${task.padEnd(taskNameWidth)}${routingStr.padEnd(routingWidth)}${status}`);
1122
+ }
1123
+ console.log();
1124
+ const taskChoice = await askChoice(rl, "", [
1125
+ `Keep defaults (use ${provider} for everything available)`,
1126
+ "Customize individual tasks",
1127
+ "Use same provider for ALL tasks (including dream)",
1128
+ ]);
1129
+ if (taskChoice === 1) {
1130
+ // Customize individual tasks
1131
+ console.log();
1132
+ console.log(`${DIM}For each task, pick a provider and model. Press Enter to keep the default.${RESET}`);
1133
+ for (const task of tasks) {
1134
+ console.log();
1135
+ console.log(` ${BOLD}${task}${RESET} ${DIM}(${TASK_DESCRIPTIONS[task]})${RESET}`);
1136
+ const currentTaskRouting = effectiveRouting[task];
1137
+ const useDefault = await askYesNo(rl, ` Keep ${currentTaskRouting.provider} / ${currentTaskRouting.model}?`, true);
1138
+ if (!useDefault) {
1139
+ // Pick a provider for this task
1140
+ const taskProvider = await pickProvider(rl, dynamicModels, ` Provider for ${task}`, currentTaskRouting.provider);
1141
+ // Pick a model
1142
+ let taskModel;
1143
+ if (taskProvider === "ollama" || taskProvider === "lmstudio") {
1144
+ taskModel = await pickModel(rl, taskProvider, dynamicModels, ` Model for ${task}`);
1145
+ }
1146
+ else if (taskProvider === "custom") {
1147
+ taskModel = await askInput(rl, " Model name");
1148
+ }
1149
+ else {
1150
+ taskModel = await pickModel(rl, taskProvider, dynamicModels, ` Model for ${task}`);
1151
+ }
1152
+ taskOverrides[task] = { provider: taskProvider, model: taskModel };
1153
+ }
898
1154
  }
899
- catch {
900
- // Non-critical — rules sync is best-effort during setup
1155
+ // Dream configuration
1156
+ console.log();
1157
+ console.log(` ${BOLD}dream${RESET} ${DIM}(${TASK_DESCRIPTIONS.dream})${RESET}`);
1158
+ console.log(` ${DIM}Dream mode runs offline consolidation — discovering relationships,`);
1159
+ console.log(` generating summaries, and scoring memories. Defaults to Ollama (free/local).${RESET}`);
1160
+ dreamEnabled = await askYesNo(rl, ` Enable dream mode?`, dreamEnabled);
1161
+ if (dreamEnabled) {
1162
+ const keepDreamDefault = await askYesNo(rl, ` Keep ${dreamProvider} / ${dreamModel || "default"}?`, true);
1163
+ if (!keepDreamDefault) {
1164
+ dreamProvider = await pickProvider(rl, dynamicModels, ` Provider for dream`, dreamProvider);
1165
+ dreamModel = await pickModel(rl, dreamProvider, dynamicModels, ` Model for dream`);
1166
+ }
1167
+ taskOverrides.dream = { provider: dreamProvider, model: dreamModel };
1168
+ }
1169
+ }
1170
+ else if (taskChoice === 2) {
1171
+ // Use same provider for ALL tasks including dream
1172
+ console.log();
1173
+ console.log(` ${DIM}All tasks will use ${provider} / ${model}.${RESET}`);
1174
+ for (const task of tasks) {
1175
+ let taskModel = model;
1176
+ if (task === "structuring") {
1177
+ taskModel = getStructuringModel(provider, model);
1178
+ }
1179
+ taskOverrides[task] = { provider, model: taskModel };
1180
+ }
1181
+ dreamEnabled = await askYesNo(rl, ` Enable dream mode with ${provider}?`, true);
1182
+ if (dreamEnabled) {
1183
+ dreamProvider = provider;
1184
+ dreamModel = model;
1185
+ taskOverrides.dream = { provider, model };
901
1186
  }
902
1187
  }
1188
+ // taskChoice === 0: keep defaults, do nothing
903
1189
  }
904
1190
  else {
905
1191
  console.log();
906
- console.log(`${DIM}Step 4/4 \u2014 IDE integration: no IDEs detected${RESET}`);
1192
+ console.log(`${DIM}Step 4/5 \u2014 Task routing: skipped (no provider)${RESET}`);
1193
+ }
1194
+ // ─── Step 5/5 — IDE integration (enhanced) ───────────────────────
1195
+ const detectedIdes = await detectIDEs(projectDir);
1196
+ const configuredIdes = [];
1197
+ console.log();
1198
+ console.log(`${BOLD}Step 5/5${RESET} ${DIM}\u2014${RESET} IDE Integration`);
1199
+ console.log();
1200
+ const ideLabels = {
1201
+ claude: "Claude Code",
1202
+ cursor: "Cursor",
1203
+ codex: "Codex",
1204
+ };
1205
+ // Build IDE options: show detected ones and offer to create missing ones
1206
+ const allIdeKeys = ["claude", "cursor", "codex"];
1207
+ const ideOptions = [];
1208
+ const ideKeyForOption = []; // parallel array mapping option index to IDE key
1209
+ for (const ide of allIdeKeys) {
1210
+ const isDetected = detectedIdes.includes(ide);
1211
+ const label = ideLabels[ide] ?? ide;
1212
+ if (isDetected) {
1213
+ ideOptions.push(`${label} (detected)`);
1214
+ }
1215
+ else if (ide === "claude") {
1216
+ // Claude CLI needs to be installed, can't just create a directory
1217
+ ideOptions.push(`${label} ${DIM}(not detected \u2014 install Claude CLI first)${RESET}`);
1218
+ }
1219
+ else {
1220
+ // Offer to create the directory
1221
+ ideOptions.push(`${label} ${DIM}(create .${ide}/ \u2014 not detected)${RESET}`);
1222
+ }
1223
+ ideKeyForOption.push(ide);
1224
+ }
1225
+ ideOptions.push("All");
1226
+ ideOptions.push("Skip");
1227
+ if (detectedIdes.length > 0) {
1228
+ const detectedNames = detectedIdes.map((id) => ideLabels[id] ?? id).join(", ");
1229
+ console.log(`Detected: ${GREEN}${detectedNames}${RESET}`);
1230
+ }
1231
+ else {
1232
+ console.log(`${DIM}No IDE integrations detected in this directory.${RESET}`);
1233
+ }
1234
+ const ideIndex = await askChoice(rl, "", ideOptions);
1235
+ let idesToSetup = [];
1236
+ if (ideIndex < allIdeKeys.length) {
1237
+ // Individual IDE selected
1238
+ idesToSetup = [ideKeyForOption[ideIndex]];
1239
+ }
1240
+ else if (ideIndex === allIdeKeys.length) {
1241
+ // "All"
1242
+ idesToSetup = [...allIdeKeys];
1243
+ }
1244
+ // Last option is "Skip"
1245
+ for (const ide of idesToSetup) {
1246
+ // For non-detected IDEs (except claude), create the directory first
1247
+ if (!detectedIdes.includes(ide) && ide !== "claude") {
1248
+ const dirPath = path.join(projectDir, `.${ide}`);
1249
+ try {
1250
+ await fs.mkdir(dirPath, { recursive: true });
1251
+ console.log(` ${CHECK} Created .${ide}/ directory`);
1252
+ }
1253
+ catch (err) {
1254
+ const msg = err instanceof Error ? err.message : String(err);
1255
+ console.log(` ${CROSS} Could not create .${ide}/: ${msg}`);
1256
+ continue;
1257
+ }
1258
+ }
1259
+ const result = await setupIDE(ide, projectDir);
1260
+ if (result.success) {
1261
+ console.log(` ${CHECK} ${result.message}`);
1262
+ configuredIdes.push(ide);
1263
+ }
1264
+ else {
1265
+ console.log(` ${CROSS} ${ideLabels[ide] ?? ide}: ${result.message}`);
1266
+ }
1267
+ }
1268
+ // Sync global rules
1269
+ if (idesToSetup.length > 0) {
1270
+ try {
1271
+ const { syncToTarget } = await import("./rulesGen.js");
1272
+ const { GnosysDB } = await import("./db.js");
1273
+ const db = GnosysDB.openCentral();
1274
+ await syncToTarget(db, projectDir, "global", null);
1275
+ db.close();
1276
+ }
1277
+ catch {
1278
+ // Non-critical — rules sync is best-effort during setup
1279
+ }
907
1280
  }
908
1281
  // ─── Compute structuring model ────────────────────────────────────
909
- const structuringModel = isSkip ? "" : getStructuringModel(provider, model);
1282
+ const structuringModel = isSkip ? "" : (taskOverrides.structuring?.model ?? getStructuringModel(provider, model));
1283
+ // ─── Write config to gnosys.json ─────────────────────────────────
1284
+ if (!isSkip) {
1285
+ // Determine which store path to write to — prefer project, fall back to global
1286
+ let storePath;
1287
+ const projectStore = path.join(projectDir, ".gnosys");
1288
+ const globalStore = path.join(os.homedir(), ".gnosys");
1289
+ if (fsSync.existsSync(path.join(projectStore, "gnosys.json"))) {
1290
+ storePath = projectStore;
1291
+ }
1292
+ else if (fsSync.existsSync(path.join(globalStore, "gnosys.json"))) {
1293
+ storePath = globalStore;
1294
+ }
1295
+ else {
1296
+ // Default to global store — create directory if needed
1297
+ await fs.mkdir(globalStore, { recursive: true });
1298
+ storePath = globalStore;
1299
+ }
1300
+ // Build the config updates
1301
+ // Build LLM config update, preserving existing provider-specific settings
1302
+ const existingLlm = existingConfig?.llm;
1303
+ const existingProviderConfig = existingLlm
1304
+ ? existingLlm[provider]
1305
+ : undefined;
1306
+ const providerConfigBase = (typeof existingProviderConfig === "object" && existingProviderConfig !== null)
1307
+ ? existingProviderConfig
1308
+ : {};
1309
+ const configUpdates = {
1310
+ llm: {
1311
+ ...(existingLlm ?? {}),
1312
+ defaultProvider: provider,
1313
+ [provider]: {
1314
+ ...providerConfigBase,
1315
+ model,
1316
+ },
1317
+ },
1318
+ };
1319
+ // Task model overrides — only write if the user actually changed them
1320
+ const taskModelsUpdate = {};
1321
+ if (taskOverrides.structuring) {
1322
+ taskModelsUpdate.structuring = taskOverrides.structuring;
1323
+ }
1324
+ if (taskOverrides.synthesis) {
1325
+ taskModelsUpdate.synthesis = taskOverrides.synthesis;
1326
+ }
1327
+ if (taskOverrides.vision) {
1328
+ taskModelsUpdate.vision = taskOverrides.vision;
1329
+ }
1330
+ if (taskOverrides.transcription) {
1331
+ taskModelsUpdate.transcription = taskOverrides.transcription;
1332
+ }
1333
+ if (Object.keys(taskModelsUpdate).length > 0) {
1334
+ configUpdates.taskModels = taskModelsUpdate;
1335
+ }
1336
+ // Dream configuration
1337
+ configUpdates.dream = {
1338
+ ...(existingConfig?.dream ?? {}),
1339
+ enabled: dreamEnabled,
1340
+ provider: dreamProvider,
1341
+ ...(dreamModel ? { model: dreamModel } : {}),
1342
+ };
1343
+ try {
1344
+ await updateConfig(storePath, configUpdates);
1345
+ console.log();
1346
+ console.log(` ${CHECK} Config written to ${storePath}/gnosys.json`);
1347
+ }
1348
+ catch (err) {
1349
+ const msg = err instanceof Error ? err.message : String(err);
1350
+ console.log();
1351
+ console.log(` ${WARN} Could not write config: ${msg}`);
1352
+ }
1353
+ }
910
1354
  // ─── Summary ──────────────────────────────────────────────────────
1355
+ // Compute final effective routing for summary display
1356
+ const summaryRouting = {};
1357
+ const taskNames = ["structuring", "synthesis", "vision", "transcription", "dream"];
1358
+ for (const task of taskNames) {
1359
+ if (isSkip) {
1360
+ summaryRouting[task] = "not configured";
1361
+ continue;
1362
+ }
1363
+ if (task === "dream") {
1364
+ if (!dreamEnabled) {
1365
+ summaryRouting[task] = "disabled";
1366
+ }
1367
+ else {
1368
+ const p = taskOverrides.dream?.provider ?? dreamProvider;
1369
+ const m = taskOverrides.dream?.model ?? (dreamModel || "default");
1370
+ summaryRouting[task] = `${p} / ${m}`;
1371
+ }
1372
+ continue;
1373
+ }
1374
+ if (taskOverrides[task]) {
1375
+ summaryRouting[task] = `${taskOverrides[task].provider} / ${taskOverrides[task].model}`;
1376
+ }
1377
+ else {
1378
+ // Default routing
1379
+ const p = provider;
1380
+ let m = model;
1381
+ if (task === "structuring")
1382
+ m = getStructuringModel(p, m);
1383
+ summaryRouting[task] = `${p} / ${m}`;
1384
+ }
1385
+ }
911
1386
  const summaryRows = [
912
1387
  ["Provider:", isSkip ? "none" : provider],
913
1388
  ["Model:", model || "none"],
914
- ["Structuring:", structuringModel || "n/a"],
915
- ["API key:", apiKeyWritten ? "~/.config/gnosys/.env" : "not set"],
1389
+ ["API key:", apiKeyWritten ? `${apiKeySource} ${CHECK}` : "not set"],
1390
+ ["", ""],
1391
+ ["Task Routing:", ""],
1392
+ [" structuring:", summaryRouting.structuring],
1393
+ [" synthesis:", summaryRouting.synthesis],
1394
+ [" vision:", summaryRouting.vision],
1395
+ [" transcription:", summaryRouting.transcription],
1396
+ [" dream:", summaryRouting.dream],
916
1397
  ];
917
1398
  if (configuredIdes.length > 0) {
918
- const ideLabels = {
919
- claude: "Claude Code",
920
- cursor: "Cursor",
921
- codex: "Codex",
922
- };
1399
+ summaryRows.push(["", ""]);
923
1400
  const ideNames = configuredIdes.map((id) => ideLabels[id] ?? id).join(", ");
924
1401
  summaryRows.push(["IDEs:", ideNames]);
925
1402
  }
@@ -936,6 +1413,8 @@ export async function runSetup(opts) {
936
1413
  ides: configuredIdes,
937
1414
  mode: "agent",
938
1415
  upgraded,
1416
+ taskOverrides: Object.keys(taskOverrides).length > 0 ? taskOverrides : undefined,
1417
+ dreamEnabled,
939
1418
  };
940
1419
  }
941
1420
  catch (err) {