@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 +7 -0
- package/LICENSE +1 -1
- package/README.md +47 -3
- package/dist/cli.js +280 -47
- package/dist/cli.js.map +1 -1
- package/docs/ph-manual.md +43 -2
- package/hooks/claude/README.md +1 -1
- package/hooks/gemini/README.md +1 -1
- package/package.json +6 -1
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
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
|
|
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
|
|
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 `
|
|
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.
|
|
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: "
|
|
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
|
|
3272
|
-
|
|
3273
|
-
|
|
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 }, "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
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
|
-
|
|
5352
|
+
return;
|
|
5216
5353
|
}
|
|
5217
5354
|
const cfg = load();
|
|
5218
5355
|
const key = args[1];
|
|
5219
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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": {
|