@gi4nks/ph 1.6.0 → 1.7.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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [1.7.0](https://github.com/gi4nks/ph/compare/v1.6.0...v1.7.0) (2026-10-18)
2
+
3
+
4
+ ### Features
5
+
6
+ * integrate Wise providers in ph ([4f8094e](https://github.com/gi4nks/ph/commit/4f8094e506430f43a96eaca299c6fc85a65870a9))
7
+
1
8
  # [1.6.0](https://github.com/gi4nks/ph/compare/v1.5.0...v1.6.0) (2026-10-09)
2
9
 
3
10
 
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Gianluca
3
+ Copyright (c) 2026 gi4nks
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -7,9 +7,10 @@
7
7
  - Captures conversations through a transparent command wrapper, native hooks, direct logging, or history import.
8
8
  - Searches prompts and responses with SQLite FTS5, or searches by semantic similarity with Ollama embeddings and sqlite-vec.
9
9
  - Groups records by project, tool, language, role, tags, time, and quality metadata.
10
- - Builds project summaries and an append-only analysis history with optional Ollama or Gemini analysis.
10
+ - Builds project summaries and an append-only analysis history with Ollama, Gemini, OpenRouter, or MLX-Serve through `@gi4nks/wise`.
11
11
  - Exposes prompt history and project knowledge through an MCP server over stdio.
12
12
  - Provides an interactive terminal browser for filtering, reviewing, editing metadata, starring, exporting, and rerunning prompts.
13
+ - The TUI `p` action sends the selected prompt to a configured Wise analysis provider; `r` reruns it with its original CLI tool.
13
14
  - Supports optional HTTP sync between a local database and a remote `ph` server.
14
15
 
15
16
  ## Install
@@ -30,7 +31,7 @@ npm run build
30
31
  npm link
31
32
  ```
32
33
 
33
- Requires Node.js 20 or later. Ollama is optional; it is used for local analysis and semantic embeddings. Gemini can be used for analysis when configured.
34
+ Requires Node.js 22 or later (the Wise/AI SDK provider stack requires Node 22). Ollama is optional; it is used for local analysis and semantic embeddings. Gemini can be used for analysis when configured.
34
35
 
35
36
  ## Quick start
36
37
 
@@ -121,6 +122,40 @@ ph config set analyze-provider gemini
121
122
  ph config set gemini-api-key "$GEMINI_API_KEY"
122
123
  ```
123
124
 
125
+ For OpenRouter, use the environment or enter the key through ph's hidden prompt, then select an exact model ID:
126
+
127
+ ```bash
128
+ export OPENROUTER_API_KEY="your_openrouter_api_key"
129
+ ph models openrouter
130
+ ph config set openrouter-api-key # prompts securely; stored in ~/.ph_config.json
131
+ ph config set analyze-provider openrouter
132
+ ph config set openrouter-model "provider/model-id"
133
+ ph config show # shows active settings, redacts keys
134
+ ph analyze
135
+ ```
136
+
137
+ For MLX-Serve on Parmenide, open an SSH tunnel to its API port (11234 by default),
138
+ then select an ID returned by the server. Keep the tunnel running in a separate terminal:
139
+
140
+ ```bash
141
+ # Terminal 1
142
+ ssh -N -L 11234:127.0.0.1:11234 parmenide
143
+ ```
144
+
145
+ ```bash
146
+ # Terminal 2
147
+ ph models mlx-serve
148
+ ph config set analyze-provider mlx-serve
149
+ ph config set mlx-serve-model "exact-model-id-from-server"
150
+ ph analyze
151
+ ```
152
+
153
+ The MLX-Serve URL defaults to `http://127.0.0.1:11234/v1`; override it with
154
+ `MLX_SERVE_BASE_URL` or `ph config set mlx-serve-url <url>`. If the server requires
155
+ authentication, set `MLX_SERVE_API_KEY` or run `ph config set mlx-serve-api-key`
156
+ for a hidden prompt. `ph config show` redacts credentials. The model catalog
157
+ marks installed models as `[loaded]` or `[unloaded]`.
158
+
124
159
  Project summaries merge deduplicated insights and technical decisions. Individual analysis results remain in an append-only memory timeline. Analysis and embeddings require the corresponding provider to be available; basic capture and full-text search do not.
125
160
 
126
161
  ## MCP integration
@@ -160,9 +195,13 @@ Configuration is stored in `~/.ph_config.json`; the database defaults to `~/.pro
160
195
  | Setting | Default | Purpose |
161
196
  |---|---|---|
162
197
  | `db-path` | `~/.prompt_history.db` | SQLite database location |
163
- | `analyze-provider` | `ollama` | Analysis provider: `ollama` or `gemini` |
198
+ | `analyze-provider` | `ollama` | Analysis provider: `ollama`, `gemini`, `openrouter`, or `mlx-serve` |
164
199
  | `ollama-url` | `http://localhost:11434` | Ollama endpoint |
165
200
  | `ollama-model` | `llama3.1:latest` | Analysis model |
201
+ | `openrouter-model` | unset | Exact OpenRouter model ID, including its provider prefix |
202
+ | `openrouter-url` | `https://openrouter.ai/api/v1` | OpenRouter-compatible API root |
203
+ | `mlx-serve-model` | unset | Exact model ID returned by MLX-Serve |
204
+ | `mlx-serve-url` | `http://127.0.0.1:11234/v1` | MLX-Serve API root, normally the Parmenide SSH tunnel |
166
205
  | `ollama-embed-model` | `nomic-embed-text-v2-moe` | Embedding model; current schema expects 768 dimensions |
167
206
  | `background-analysis` | `false` | Analyze newly captured prompts asynchronously |
168
207
  | `filter-min-length` | `15` | Minimum prompt length accepted by filtering |
@@ -170,6 +209,11 @@ Configuration is stored in `~/.ph_config.json`; the database defaults to `~/.pro
170
209
  | `remote-url` | unset | Remote `ph` server; `PH_REMOTE_URL` takes precedence |
171
210
  | `remote-api-key` | unset | Optional server authentication key |
172
211
 
212
+ Provider credentials can be stored in `~/.ph_config.json` through the hidden
213
+ `ph config set <provider>-api-key` prompts or supplied through environment
214
+ variables. Environment values take precedence; MLX-Serve also accepts the
215
+ existing `GLB_OMLX_API_KEY` variable.
216
+
173
217
  ## Development
174
218
 
175
219
  ```bash
package/dist/cli.js CHANGED
@@ -21,6 +21,7 @@ function load() {
21
21
  }
22
22
  function save(cfg) {
23
23
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 384 });
24
+ fs.chmodSync(CONFIG_PATH, 384);
24
25
  }
25
26
 
26
27
  // src/db/index.ts
@@ -2231,10 +2232,13 @@ import { AppHeader, THEMES } from "@gi4nks/ink";
2231
2232
  // package.json
2232
2233
  var package_default = {
2233
2234
  name: "@gi4nks/ph",
2234
- version: "1.5.0",
2235
+ version: "1.6.0",
2235
2236
  description: "Transparent CLI wrapper that captures and indexes AI prompts",
2236
2237
  type: "module",
2237
2238
  license: "MIT",
2239
+ engines: {
2240
+ node: ">=22"
2241
+ },
2238
2242
  bin: {
2239
2243
  ph: "dist/cli.js"
2240
2244
  },
@@ -2287,8 +2291,10 @@ var package_default = {
2287
2291
  },
2288
2292
  dependencies: {
2289
2293
  "@gi4nks/ink": "^1.5.0",
2294
+ "@gi4nks/wise": "^2.1.0",
2290
2295
  "@lydell/node-pty": "^1.2.0-beta.3",
2291
2296
  "@modelcontextprotocol/sdk": "^1.29.0",
2297
+ ai: "^7.0.136",
2292
2298
  "better-sqlite3": "^12.8.0",
2293
2299
  ink: "^7.0.5",
2294
2300
  react: "^19.2.7",
@@ -2385,7 +2391,7 @@ var Footer = ({
2385
2391
  );
2386
2392
  if (hasProject2) base.push({ key: "C", description: "chat" });
2387
2393
  base.push({ key: "r", description: "rerun" });
2388
- base.push({ key: "p", description: "provider" });
2394
+ base.push({ key: "p", description: "model" });
2389
2395
  base.push("|");
2390
2396
  base.push(
2391
2397
  { key: "x", description: "del" },
@@ -3062,6 +3068,52 @@ var PreviewPane = ({
3062
3068
  )), /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, topIndicator), paddedLines.map((line, i) => /* @__PURE__ */ React6.createElement(Text5, { key: i }, line.segments.length > 0 ? line.segments.map((seg, j) => /* @__PURE__ */ React6.createElement(Text5, { key: j, color: seg.color, dimColor: seg.dim }, seg.text)) : " ")), /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, botIndicator));
3063
3069
  };
3064
3070
 
3071
+ // src/config/provider-summary.ts
3072
+ function getProviderSummaries(cfg) {
3073
+ const active = cfg.analyzeProvider ?? "ollama";
3074
+ return [
3075
+ {
3076
+ id: "ollama",
3077
+ label: "Ollama",
3078
+ model: cfg.ollamaModel ?? "llama3.1:latest",
3079
+ url: cfg.ollamaUrl ?? "http://localhost:11434",
3080
+ credential: "not required",
3081
+ active: active === "ollama",
3082
+ ready: true
3083
+ },
3084
+ {
3085
+ id: "gemini",
3086
+ label: "Gemini",
3087
+ model: "gemini-2.5-flash",
3088
+ url: "(default)",
3089
+ credential: process.env.GEMINI_API_KEY || cfg.geminiApiKey ? "configured" : "not set",
3090
+ active: active === "gemini",
3091
+ ready: Boolean(process.env.GEMINI_API_KEY || cfg.geminiApiKey)
3092
+ },
3093
+ {
3094
+ id: "openrouter",
3095
+ label: "OpenRouter",
3096
+ model: process.env.OPENROUTER_MODEL ?? cfg.openrouterModel ?? "(not set)",
3097
+ url: process.env.OPENROUTER_BASE_URL ?? cfg.openrouterUrl ?? "https://openrouter.ai/api/v1",
3098
+ credential: process.env.OPENROUTER_API_KEY || cfg.openrouterApiKey ? "configured" : "not set",
3099
+ active: active === "openrouter",
3100
+ ready: Boolean((process.env.OPENROUTER_API_KEY || cfg.openrouterApiKey) && (process.env.OPENROUTER_MODEL || cfg.openrouterModel))
3101
+ },
3102
+ {
3103
+ id: "mlx-serve",
3104
+ label: "MLX-Serve",
3105
+ model: process.env.MLX_SERVE_MODEL ?? cfg.mlxServeModel ?? "(not set)",
3106
+ url: process.env.MLX_SERVE_BASE_URL ?? cfg.mlxServeUrl ?? "http://127.0.0.1:11234/v1",
3107
+ credential: process.env.MLX_SERVE_API_KEY || process.env.GLB_OMLX_API_KEY || cfg.mlxServeApiKey ? "configured" : "not required",
3108
+ active: active === "mlx-serve",
3109
+ ready: Boolean(process.env.MLX_SERVE_MODEL || cfg.mlxServeModel)
3110
+ }
3111
+ ];
3112
+ }
3113
+ function formatProviderSummary(provider) {
3114
+ return `${provider.active ? "*" : " "} ${provider.label}: model=${provider.model}, url=${provider.url}, key=${provider.credential}, setup=${provider.ready ? "ready" : "incomplete"}`;
3115
+ }
3116
+
3065
3117
  // src/ui/BrowseApp.tsx
3066
3118
  function copyToClipboard(text) {
3067
3119
  try {
@@ -3254,33 +3306,26 @@ var RerunView = ({ entry, onConfirm, onClose, theme }) => {
3254
3306
  });
3255
3307
  return /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React7.createElement(Box5, { marginBottom: 1 }, /* @__PURE__ */ React7.createElement(Text6, { color: theme.primary, bold: true }, "Rerun Prompt #", entry.id)), /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", marginBottom: 1 }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Tool: ", /* @__PURE__ */ React7.createElement(Text6, { color: theme.warning }, entry.tool)), /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Date: ", /* @__PURE__ */ React7.createElement(Text6, null, formatTimestamp2(entry.timestamp))), meta.project && /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Project: ", /* @__PURE__ */ React7.createElement(Text6, { color: theme.accent }, meta.project)), git && /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Git: ", /* @__PURE__ */ React7.createElement(Text6, { color: theme.warning }, "Captured on branch: ", git.branch, ", ", git.files?.length || 0, " files modified"))), /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", borderStyle: "single", borderColor: theme.dim, padding: 1 }, /* @__PURE__ */ React7.createElement(Box5, { marginBottom: 1 }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Tool: "), /* @__PURE__ */ React7.createElement(Text6, { color: field === "tool" ? theme.primary : "white", bold: field === "tool" }, toolValue, field === "tool" ? "\u2588" : "")), /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column" }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Prompt: "), /* @__PURE__ */ React7.createElement(Box5, { borderStyle: "round", borderColor: field === "prompt" ? theme.primary : theme.dim, paddingX: 1 }, /* @__PURE__ */ React7.createElement(Text6, { color: field === "prompt" ? theme.primary : "white" }, promptValue, field === "prompt" ? "\u2588" : "")))), /* @__PURE__ */ React7.createElement(Box5, { marginTop: 1 }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Tab switch \xB7 Enter run \xB7 ESC cancel")));
3256
3308
  };
3257
- var KNOWN_PROVIDERS = [
3258
- { key: "1", label: "claude", tool: "claude" },
3259
- { key: "2", label: "gemini", tool: "gemini" },
3260
- { key: "3", label: "opencode", tool: "opencode" },
3261
- { key: "4", label: "codex", tool: "codex" },
3262
- { key: "5", label: "ollama", tool: "ollama" },
3263
- { key: "6", label: "chatgpt", tool: "chatgpt" }
3264
- ];
3265
3309
  var ProviderPicker = ({ entry, onPick, onClose, theme }) => {
3310
+ const providers = getProviderSummaries(load());
3266
3311
  useInput3((char) => {
3267
3312
  if (char === "q" || char === "\x1B") {
3268
3313
  onClose();
3269
3314
  return;
3270
3315
  }
3271
- const provider = KNOWN_PROVIDERS.find((p) => p.key === char);
3272
- if (provider) {
3273
- onPick(provider.tool, entry.prompt);
3316
+ const index = Number(char) - 1;
3317
+ const provider = providers[index];
3318
+ if (provider?.ready) {
3319
+ onPick(`wise:${provider.id}`, entry.prompt);
3274
3320
  }
3275
3321
  });
3276
- return /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", padding: 2 }, /* @__PURE__ */ React7.createElement(Text6, { bold: true, color: theme.primary }, "Send prompt #", entry.id, " to:"), /* @__PURE__ */ React7.createElement(Box5, { marginTop: 1, flexDirection: "column" }, KNOWN_PROVIDERS.map((p) => /* @__PURE__ */ React7.createElement(Text6, { key: p.key }, /* @__PURE__ */ React7.createElement(Text6, { color: theme.warning }, p.key), /* @__PURE__ */ React7.createElement(Text6, null, ": ", p.label)))), /* @__PURE__ */ React7.createElement(Box5, { marginTop: 1 }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Pick a number \xB7 q to cancel")));
3322
+ return /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", padding: 2 }, /* @__PURE__ */ React7.createElement(Text6, { bold: true, color: theme.primary }, "Run prompt #", entry.id, " with an analysis provider:"), /* @__PURE__ */ React7.createElement(Box5, { marginTop: 1, flexDirection: "column" }, providers.map((provider, index) => /* @__PURE__ */ React7.createElement(Text6, { key: provider.id, color: provider.ready ? "white" : theme.dim }, /* @__PURE__ */ React7.createElement(Text6, { color: provider.ready ? theme.warning : theme.dim }, provider.ready ? index + 1 : " "), /* @__PURE__ */ React7.createElement(Text6, null, ": ", formatProviderSummary(provider).trim())))), /* @__PURE__ */ React7.createElement(Box5, { marginTop: 1 }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Pick a configured provider \xB7 q to cancel \xB7 r reruns with the original CLI tool")));
3277
3323
  };
3278
3324
  var SETTING_FIELDS = [
3279
3325
  { key: "backgroundAnalysis", label: "Auto-analysis on capture", type: "boolean" },
3280
3326
  { key: "ollamaUrl", label: "Ollama URL", type: "string" },
3281
3327
  { key: "ollamaModel", label: "Ollama model (analysis)", type: "string" },
3282
3328
  { key: "ollamaEmbedModel", label: "Ollama model (embeddings)", type: "string" },
3283
- { key: "analyzeProvider", label: "Analysis provider", type: "string" },
3284
3329
  { key: "filterMinLength", label: "Min prompt length filter", type: "number" },
3285
3330
  { key: "filterMinRelevance", label: "Min relevance filter", type: "number" }
3286
3331
  ];
@@ -3327,7 +3372,7 @@ var SettingsView = ({ onClose, theme }) => {
3327
3372
  }
3328
3373
  }
3329
3374
  });
3330
- return /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React7.createElement(Box5, { marginBottom: 1 }, /* @__PURE__ */ React7.createElement(Text6, { color: theme.primary, bold: true }, "Settings "), dirty && /* @__PURE__ */ React7.createElement(Text6, { color: theme.warning }, "modified "), saved && /* @__PURE__ */ React7.createElement(Text6, { color: theme.success }, "\u2713 saved")), /* @__PURE__ */ React7.createElement(Box5, { borderStyle: "single", borderColor: theme.dim, flexDirection: "column", padding: 1 }, SETTING_FIELDS.map((field, i) => {
3375
+ return /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React7.createElement(Box5, { marginBottom: 1 }, /* @__PURE__ */ React7.createElement(Text6, { color: theme.primary, bold: true }, "Settings "), dirty && /* @__PURE__ */ React7.createElement(Text6, { color: theme.warning }, "modified "), saved && /* @__PURE__ */ React7.createElement(Text6, { color: theme.success }, "\u2713 saved")), /* @__PURE__ */ React7.createElement(Box5, { borderStyle: "single", borderColor: theme.dim, flexDirection: "column", padding: 1 }, /* @__PURE__ */ React7.createElement(Text6, { bold: true, color: theme.primary }, "Analysis providers (* = active; edit with `ph config set`)"), getProviderSummaries(cfg).map((provider) => /* @__PURE__ */ React7.createElement(Text6, { key: provider.id, color: provider.active ? theme.success : theme.dim }, formatProviderSummary(provider))), /* @__PURE__ */ React7.createElement(Text6, null, " "), SETTING_FIELDS.map((field, i) => {
3331
3376
  const isCur = i === cursor;
3332
3377
  const val = cfg[field.key];
3333
3378
  let display;
@@ -4946,9 +4991,87 @@ var GeminiProvider = class {
4946
4991
  }
4947
4992
  };
4948
4993
 
4994
+ // src/ai/wise.ts
4995
+ import { generateText } from "ai";
4996
+ import { createAIModel } from "@gi4nks/wise";
4997
+ var OPENROUTER_DEFAULT_URL = "https://openrouter.ai/api/v1";
4998
+ var MLX_SERVE_DEFAULT_URL = "http://127.0.0.1:11234/v1";
4999
+ var WiseLLMProvider = class {
5000
+ name;
5001
+ model;
5002
+ constructor(wiseProvider, displayName, modelId, config) {
5003
+ this.name = `${displayName} (${modelId})`;
5004
+ this.model = createAIModel(wiseProvider, modelId, config);
5005
+ }
5006
+ async generate(prompt) {
5007
+ const result = await generateText({
5008
+ model: this.model,
5009
+ prompt,
5010
+ // OpenRouter requests can incur cost. Avoid hidden automatic replays.
5011
+ maxRetries: 0
5012
+ });
5013
+ return result.text;
5014
+ }
5015
+ };
5016
+ async function discoverModels(provider, apiKey, baseUrl) {
5017
+ if (provider === "mlx-serve") {
5018
+ const url2 = (baseUrl || MLX_SERVE_DEFAULT_URL).replace(/\/+$/, "");
5019
+ const response2 = await fetch(`${url2}/models`, {
5020
+ headers: { Authorization: `Bearer ${apiKey}` },
5021
+ signal: AbortSignal.timeout(15e3)
5022
+ });
5023
+ if (!response2.ok) throw new Error(`HTTP ${response2.status}`);
5024
+ const data2 = await response2.json();
5025
+ return (data2.data ?? []).filter((model) => typeof model.id === "string" && model.id.length > 0).map((model) => ({ id: model.id, name: model.name || model.id, loaded: model.loaded, state: model.state }));
5026
+ }
5027
+ const url = (baseUrl || OPENROUTER_DEFAULT_URL).replace(/\/+$/, "");
5028
+ const response = await fetch(`${url}/models`, {
5029
+ headers: { Authorization: `Bearer ${apiKey}` },
5030
+ signal: AbortSignal.timeout(15e3)
5031
+ });
5032
+ if (!response.ok) {
5033
+ throw new Error(`HTTP ${response.status}`);
5034
+ }
5035
+ const data = await response.json();
5036
+ return (data.data ?? []).filter((model) => typeof model.id === "string" && model.id.length > 0).map((model) => ({ id: model.id, name: model.name || model.id }));
5037
+ }
5038
+
4949
5039
  // src/ai/provider.ts
4950
- function getProvider(cfg) {
4951
- const provider = cfg.analyzeProvider ?? "ollama";
5040
+ function getProviderSetupError(cfg, selectedProvider) {
5041
+ const provider = selectedProvider ?? cfg.analyzeProvider ?? "ollama";
5042
+ if (provider === "openrouter") {
5043
+ if (!(process.env.OPENROUTER_API_KEY || cfg.openrouterApiKey)) return "Set a key with `ph config set openrouter-api-key` or OPENROUTER_API_KEY.";
5044
+ if (!(process.env.OPENROUTER_MODEL || cfg.openrouterModel)) return "Choose a model with `ph config set openrouter-model <provider/model>`.";
5045
+ }
5046
+ if (provider === "mlx-serve" && !(process.env.MLX_SERVE_MODEL || cfg.mlxServeModel)) {
5047
+ return "Choose an MLX-Serve model with `ph config set mlx-serve-model <model-id>`.";
5048
+ }
5049
+ return null;
5050
+ }
5051
+ function getProvider(cfg, selectedProvider) {
5052
+ const provider = selectedProvider ?? cfg.analyzeProvider ?? "ollama";
5053
+ if (provider === "openrouter") {
5054
+ const apiKey = process.env.OPENROUTER_API_KEY ?? cfg.openrouterApiKey;
5055
+ const model = process.env.OPENROUTER_MODEL ?? cfg.openrouterModel;
5056
+ if (!apiKey || !model) return null;
5057
+ return new WiseLLMProvider("openai", "OpenRouter", model, {
5058
+ openai: {
5059
+ apiKey,
5060
+ baseUrl: process.env.OPENROUTER_BASE_URL ?? cfg.openrouterUrl ?? OPENROUTER_DEFAULT_URL
5061
+ }
5062
+ });
5063
+ }
5064
+ if (provider === "mlx-serve") {
5065
+ const model = process.env.MLX_SERVE_MODEL ?? cfg.mlxServeModel;
5066
+ if (!model) return null;
5067
+ const apiKey = process.env.MLX_SERVE_API_KEY ?? process.env.GLB_OMLX_API_KEY ?? cfg.mlxServeApiKey ?? "local";
5068
+ return new WiseLLMProvider("omlx", "MLX-Serve", model, {
5069
+ omlx: {
5070
+ apiKey,
5071
+ baseUrl: process.env.MLX_SERVE_BASE_URL ?? cfg.mlxServeUrl ?? MLX_SERVE_DEFAULT_URL
5072
+ }
5073
+ });
5074
+ }
4952
5075
  if (provider === "gemini") {
4953
5076
  const apiKey = process.env.GEMINI_API_KEY ?? cfg.geminiApiKey;
4954
5077
  if (!apiKey) return null;
@@ -4979,7 +5102,9 @@ async function cmdImport(db, cfg, args) {
4979
5102
  if (analyze) {
4980
5103
  analyzer = getProvider(cfg);
4981
5104
  if (!analyzer) {
4982
- process.stderr.write("ph: no LLM provider configured. Run: ph config set analyze-provider ollama\n");
5105
+ const detail = getProviderSetupError(cfg) ?? "No LLM provider is configured. Run: ph config set analyze-provider ollama";
5106
+ process.stderr.write(`ph: ${detail}
5107
+ `);
4983
5108
  process.exit(1);
4984
5109
  }
4985
5110
  console.log(`Analyzing with ${analyzer.name} during import...`);
@@ -5052,7 +5177,9 @@ async function cmdAnalyze(db, cfg, args) {
5052
5177
  const pruneBelow = prune ? flags["min-score"] ? Number(flags["min-score"]) : cfg.filterMinRelevance ?? 3 : 0;
5053
5178
  const provider = getProvider(cfg);
5054
5179
  if (!provider) {
5055
- process.stderr.write("ph: no LLM provider configured. Set analyze-provider in config.\n");
5180
+ const detail = getProviderSetupError(cfg) ?? "No LLM provider is configured. Set analyze-provider in config.";
5181
+ process.stderr.write(`ph: ${detail}
5182
+ `);
5056
5183
  process.exit(1);
5057
5184
  }
5058
5185
  const allRows = db.search({ limit: 1e4 });
@@ -5206,17 +5333,29 @@ async function pushToRemote(url, apiKey, entry) {
5206
5333
  }
5207
5334
 
5208
5335
  // src/commands/config.ts
5209
- function cmdConfig(args) {
5210
- if (args.length < 3 || args[0] !== "set") {
5211
- console.log("Usage: ph config set <key> <value>");
5336
+ var SECRET_KEYS = ["gemini-api-key", "openrouter-api-key", "mlx-serve-api-key", "remote-api-key"];
5337
+ async function cmdConfig(args) {
5338
+ if (args[0] === "show") {
5339
+ const cfg2 = load();
5340
+ console.log("Analysis providers (* = active; credentials are never shown):");
5341
+ for (const provider of getProviderSummaries(cfg2)) console.log(formatProviderSummary(provider));
5342
+ console.log("Configure with: ph config set analyze-provider <ollama|gemini|openrouter|mlx-serve>");
5343
+ return;
5344
+ }
5345
+ if (args[0] !== "set" || args.length < 2) {
5346
+ console.log("Usage: ph config show | ph config set <key> [value]");
5212
5347
  console.log("Keys: gemini-api-key, db-path, analyze-provider, ollama-url, ollama-model,");
5348
+ console.log(" openrouter-model, openrouter-url, openrouter-api-key,");
5349
+ console.log(" mlx-serve-model, mlx-serve-url, mlx-serve-api-key,");
5213
5350
  console.log(" ollama-embed-model, filter-min-length, filter-min-relevance, background-analysis,");
5214
5351
  console.log(" remote-url, remote-api-key");
5215
- process.exit(1);
5352
+ return;
5216
5353
  }
5217
5354
  const cfg = load();
5218
5355
  const key = args[1];
5219
- const val = args[2];
5356
+ let val = args.slice(2).join(" ");
5357
+ if (!val && SECRET_KEYS.includes(key)) val = await readSecret(`${key}: `);
5358
+ if (!val) throw new Error(`A value is required for ${key}.`);
5220
5359
  switch (key) {
5221
5360
  case "gemini-api-key":
5222
5361
  cfg.geminiApiKey = val;
@@ -5225,10 +5364,7 @@ function cmdConfig(args) {
5225
5364
  cfg.dbPath = val;
5226
5365
  break;
5227
5366
  case "analyze-provider":
5228
- if (val !== "ollama" && val !== "gemini") {
5229
- process.stderr.write('ph: analyze-provider must be "ollama" or "gemini"\n');
5230
- process.exit(1);
5231
- }
5367
+ if (!["ollama", "gemini", "openrouter", "mlx-serve"].includes(val)) throw new Error("analyze-provider must be ollama, gemini, openrouter, or mlx-serve");
5232
5368
  cfg.analyzeProvider = val;
5233
5369
  break;
5234
5370
  case "ollama-url":
@@ -5237,24 +5373,36 @@ function cmdConfig(args) {
5237
5373
  case "ollama-model":
5238
5374
  cfg.ollamaModel = val;
5239
5375
  break;
5376
+ case "openrouter-model":
5377
+ cfg.openrouterModel = val;
5378
+ break;
5379
+ case "openrouter-url":
5380
+ cfg.openrouterUrl = val;
5381
+ break;
5382
+ case "openrouter-api-key":
5383
+ cfg.openrouterApiKey = val;
5384
+ break;
5385
+ case "mlx-serve-model":
5386
+ cfg.mlxServeModel = val;
5387
+ break;
5388
+ case "mlx-serve-url":
5389
+ cfg.mlxServeUrl = val;
5390
+ break;
5391
+ case "mlx-serve-api-key":
5392
+ cfg.mlxServeApiKey = val;
5393
+ break;
5240
5394
  case "ollama-embed-model":
5241
5395
  cfg.ollamaEmbedModel = val;
5242
5396
  break;
5243
5397
  case "filter-min-length": {
5244
5398
  const n = Number(val);
5245
- if (isNaN(n) || n < 0) {
5246
- process.stderr.write("ph: filter-min-length must be a non-negative integer\n");
5247
- process.exit(1);
5248
- }
5399
+ if (!Number.isInteger(n) || n < 0) throw new Error("filter-min-length must be a non-negative integer");
5249
5400
  cfg.filterMinLength = n;
5250
5401
  break;
5251
5402
  }
5252
5403
  case "filter-min-relevance": {
5253
5404
  const n = Number(val);
5254
- if (isNaN(n) || n < 0 || n > 10) {
5255
- process.stderr.write("ph: filter-min-relevance must be 0-10\n");
5256
- process.exit(1);
5257
- }
5405
+ if (!Number.isInteger(n) || n < 0 || n > 10) throw new Error("filter-min-relevance must be 0-10");
5258
5406
  cfg.filterMinRelevance = n;
5259
5407
  break;
5260
5408
  }
@@ -5268,12 +5416,43 @@ function cmdConfig(args) {
5268
5416
  cfg.remoteApiKey = val;
5269
5417
  break;
5270
5418
  default:
5271
- process.stderr.write(`ph: unknown config key "${key}"
5272
- `);
5273
- process.exit(1);
5419
+ throw new Error(`unknown config key "${key}"`);
5274
5420
  }
5275
5421
  save(cfg);
5276
- console.log(`Config "${key}" updated successfully.`);
5422
+ console.log(SECRET_KEYS.includes(key) ? `Config "${key}" updated (value hidden).` : `Config "${key}" updated successfully.`);
5423
+ }
5424
+ function readSecret(prompt) {
5425
+ if (!process.stdin.isTTY) throw new Error(`No value supplied for ${prompt.trim()}; use an environment variable in non-interactive shells.`);
5426
+ return new Promise((resolve, reject) => {
5427
+ const input = process.stdin;
5428
+ const previousRawMode = input.isRaw ?? false;
5429
+ let value = "";
5430
+ process.stderr.write(prompt);
5431
+ input.setRawMode(true);
5432
+ input.resume();
5433
+ const cleanup = () => {
5434
+ input.removeListener("data", onData);
5435
+ input.setRawMode(previousRawMode);
5436
+ process.stderr.write("\n");
5437
+ };
5438
+ const onData = (chunk) => {
5439
+ for (const byte of chunk) {
5440
+ if (byte === 3) {
5441
+ cleanup();
5442
+ reject(new Error("Input cancelled."));
5443
+ return;
5444
+ }
5445
+ if (byte === 13 || byte === 10) {
5446
+ cleanup();
5447
+ resolve(value);
5448
+ return;
5449
+ }
5450
+ if (byte === 127 || byte === 8) value = value.slice(0, -1);
5451
+ else if (byte >= 32) value += String.fromCharCode(byte);
5452
+ }
5453
+ };
5454
+ input.on("data", onData);
5455
+ });
5277
5456
  }
5278
5457
 
5279
5458
  // src/commands/background-analyze.ts
@@ -6454,6 +6633,40 @@ ${userPrompt}`;
6454
6633
  process.exit(child.status ?? 0);
6455
6634
  }
6456
6635
 
6636
+ // src/commands/models.ts
6637
+ async function cmdModels(cfg, args) {
6638
+ const provider = args[0] ?? cfg.analyzeProvider;
6639
+ if (provider !== "openrouter" && provider !== "mlx-serve") {
6640
+ throw new Error("Choose a provider: `ph models openrouter` or `ph models mlx-serve`.");
6641
+ }
6642
+ const cfgKey = provider === "openrouter" ? cfg.openrouterApiKey : cfg.mlxServeApiKey;
6643
+ if (provider === "openrouter" && !(process.env.OPENROUTER_API_KEY || cfgKey)) {
6644
+ throw new Error("Configure a key with `ph config set openrouter-api-key` or set OPENROUTER_API_KEY.");
6645
+ }
6646
+ const apiKey = provider === "openrouter" ? process.env.OPENROUTER_API_KEY ?? cfg.openrouterApiKey : process.env.MLX_SERVE_API_KEY ?? process.env.GLB_OMLX_API_KEY ?? cfg.mlxServeApiKey ?? "local";
6647
+ const baseUrl = provider === "openrouter" ? process.env.OPENROUTER_BASE_URL ?? cfg.openrouterUrl ?? OPENROUTER_DEFAULT_URL : process.env.MLX_SERVE_BASE_URL ?? cfg.mlxServeUrl ?? MLX_SERVE_DEFAULT_URL;
6648
+ let models;
6649
+ try {
6650
+ models = await discoverModels(provider, apiKey, baseUrl);
6651
+ } catch (error) {
6652
+ const detail = error instanceof Error ? error.message : String(error);
6653
+ const tunnel = provider === "mlx-serve" && /127\.0\.0\.1|localhost/.test(baseUrl) ? "\nParmenide: apri in un altro terminale `ssh -N -L 11234:127.0.0.1:11234 parmenide`, poi riprova. Puoi impostare un URL raggiungibile con `ph config set mlx-serve-url <url>`." : "";
6654
+ throw new Error(`Cannot query ${provider} at ${baseUrl}: ${detail}${tunnel}`);
6655
+ }
6656
+ if (models.length === 0) {
6657
+ const tunnel = provider === "mlx-serve" ? "\nSe il CLI gira sul Mac e il server su Parmenide, apri: ssh -N -L 11234:127.0.0.1:11234 parmenide" : "";
6658
+ throw new Error(`No models returned by ${provider} at ${baseUrl}. Check connectivity and credentials.${tunnel}`);
6659
+ }
6660
+ console.log(`Models listed by ${provider} at ${baseUrl}:`);
6661
+ for (const model of models) {
6662
+ const status2 = model.loaded === void 0 ? "" : model.loaded ? " [loaded]" : ` [${model.state ?? "unloaded"}]`;
6663
+ console.log(` ${model.id}${model.name !== model.id ? ` \u2014 ${model.name}` : ""}${status2}`);
6664
+ }
6665
+ if (provider === "mlx-serve") {
6666
+ console.log("\nph can list installed models even when they are unloaded. Select a model marked [loaded] to run it immediately.");
6667
+ }
6668
+ }
6669
+
6457
6670
  // src/cli.ts
6458
6671
  var USAGE = `ph \u2014 prompt history tracker
6459
6672
 
@@ -6473,6 +6686,7 @@ USAGE:
6473
6686
  ph import <source> [--dry-run] [--analyze] [--filter] [--file <path>]
6474
6687
  Import from Gemini, Claude, OpenCode, or Codex history
6475
6688
  ph analyze [--limit n] [--force] [--prune] [--dry-run] Analyze prompts with LLM
6689
+ ph models [openrouter|mlx-serve] List models from an AI provider
6476
6690
  ph mcp Start MCP server (Stdio)
6477
6691
  ph server [--port 3001] Start HTTP REST server for remote sync
6478
6692
  ph timeline [project] Show full project history with prompts and memories
@@ -6483,7 +6697,8 @@ USAGE:
6483
6697
  ph cleanup-reusability [--dry-run] [--threshold 0.7] [--force] Cleanup based on reusability
6484
6698
  ph embed-all Generate embeddings for all prompts
6485
6699
  ph log --tool <name> --prompt <text> [--response <text>] Log a prompt+response directly
6486
- ph config set <key> <value> Save config value
6700
+ ph config show Show active AI provider configuration
6701
+ ph config set <key> [value] Save config value (secret keys prompt hidden)
6487
6702
  ph browse Interactive TUI browser
6488
6703
 
6489
6704
  WRAP FLAGS (placed before the tool name):
@@ -6557,6 +6772,8 @@ EXAMPLES:
6557
6772
  ph config set background-analysis true
6558
6773
  ph config set ollama-model llama3.2:latest
6559
6774
  ph ollama-models List models available on the local Ollama instance
6775
+ ph models openrouter Discover OpenRouter models
6776
+ ph models mlx-serve Discover models on MLX-Serve (default Parmenide tunnel)
6560
6777
  `;
6561
6778
  async function openBrowser(db, opts = {}) {
6562
6779
  let pendingRerun = null;
@@ -6577,8 +6794,20 @@ async function openBrowser(db, opts = {}) {
6577
6794
  }
6578
6795
  return pendingRerun;
6579
6796
  }
6580
- function runRerun(rerun) {
6797
+ async function runRerun(rerun, dbPath, cfg) {
6581
6798
  if (!rerun) return;
6799
+ if (rerun.tool.startsWith("wise:")) {
6800
+ const providerId = rerun.tool.slice("wise:".length);
6801
+ const setupError = getProviderSetupError(cfg, providerId);
6802
+ if (setupError) throw new Error(setupError);
6803
+ const provider = getProvider(cfg, providerId);
6804
+ if (!provider) throw new Error(`Cannot create ${providerId} provider from the saved configuration.`);
6805
+ const response = await provider.generate(rerun.prompt);
6806
+ process.stdout.write(`${response}
6807
+ `);
6808
+ await cmdLog(dbPath, cfg, ["--tool", `wise:${providerId}`, "--prompt", rerun.prompt, "--response", response]);
6809
+ return;
6810
+ }
6582
6811
  const realBin = resolveRealBinary(rerun.tool);
6583
6812
  const child = spawnSync2(realBin, [rerun.prompt], { stdio: "inherit" });
6584
6813
  process.exit(child.status ?? 0);
@@ -6595,7 +6824,7 @@ async function main() {
6595
6824
  const db = new PhDB(dbPath);
6596
6825
  const rerun = await openBrowser(db);
6597
6826
  db.close();
6598
- runRerun(rerun);
6827
+ await runRerun(rerun, dbPath, cfg);
6599
6828
  process.exit(0);
6600
6829
  } else {
6601
6830
  process.stdout.write(USAGE);
@@ -6638,7 +6867,7 @@ async function main() {
6638
6867
  }
6639
6868
  });
6640
6869
  db2.close();
6641
- runRerun(rerun);
6870
+ await runRerun(rerun, dbPath, cfg);
6642
6871
  break;
6643
6872
  }
6644
6873
  const db = new PhDB(dbPath);
@@ -6712,6 +6941,10 @@ async function main() {
6712
6941
  db.close();
6713
6942
  break;
6714
6943
  }
6944
+ case "models": {
6945
+ await cmdModels(cfg, cmdArgs);
6946
+ break;
6947
+ }
6715
6948
  case "memory-migrate": {
6716
6949
  const db = new PhDB(dbPath);
6717
6950
  cmdMemoryMigrate(db);
@@ -6756,7 +6989,7 @@ async function main() {
6756
6989
  break;
6757
6990
  }
6758
6991
  case "config":
6759
- cmdConfig(cmdArgs);
6992
+ await cmdConfig(cmdArgs);
6760
6993
  break;
6761
6994
  case "_bg-analyze": {
6762
6995
  await cmdBackgroundAnalyze(dbPath, cmdArgs);
@@ -6766,7 +6999,7 @@ async function main() {
6766
6999
  const db = new PhDB(dbPath);
6767
7000
  const rerun = await openBrowser(db);
6768
7001
  db.close();
6769
- runRerun(rerun);
7002
+ await runRerun(rerun, dbPath, cfg);
6770
7003
  break;
6771
7004
  }
6772
7005
  case "chat": {