friend-assistant 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -127,10 +127,11 @@ Connectors (Gmail, Chrome control, any MCP service) are managed inside the app,
127
127
 
128
128
  | Key | What it is |
129
129
  |---|---|
130
- | `provider` | Which model answers: `claude` (API key), `claude-code` (your subscription) or `ollama` (this PC). |
130
+ | `provider` | Which model answers: `claude` (API key), `claude-code` (your subscription), `ollama` (this PC) or `openai` (any OpenAI-compatible server, such as OmniRoute). |
131
131
  | `apiKey`, `model` | The Anthropic API key and the Claude model used with it. |
132
132
  | `claudeCode.model`, `claudeCode.command`, `claudeCode.fallback` | The subscription route: model (`sonnet`, `haiku`, `opus`), the command that starts Claude Code, and `ollama` or `none` for what to use when Claude is unavailable. |
133
133
  | `ollama.url`, `ollama.model`, `ollama.vision`, `ollama.contextTokens` | The local model: server address, model name, whether it can see pictures, and how much it remembers at once. |
134
+ | `openai.url`, `openai.model`, `openai.apiKey`, `openai.vision` | An OpenAI-compatible server: its address (ending in `/v1`), the model name it knows, its API key (or empty), and whether the model can see pictures. For OmniRoute (https://github.com/diegosouzapw/OmniRoute) run `npm install -g omniroute` and `omniroute`, then `friend config set provider openai` (the defaults are OmniRoute's: `http://localhost:20128/v1` and model `auto`). Also works with LM Studio, LiteLLM, vLLM and OpenRouter. |
134
135
  | `voice.enabled`, `voice.sttModel`, `voice.ttsVoice`, `voice.language`, `voice.speakTyped`, `voice.accept`, `voice.challenge` | Speech on or off, the listening model, the speaking voice, a forced language, speaking typed replies, and the two thresholds for recognising your voice. |
135
136
  | `bind`, `port` | Where Friend listens (easier: `friend lan`). |
136
137
  | `https` | `true` serves a secure `https://` address with a certificate Friend makes for itself, so a phone may use the microphone (easier: `friend lan https on`). |
package/dist/BUILD.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "friend-assistant",
3
- "version": "0.1.3",
4
- "builtAt": "2026-10-09T05:15:27.134Z"
3
+ "version": "0.1.5",
4
+ "builtAt": "2026-10-09T06:20:45.511Z"
5
5
  }
package/dist/friend.mjs CHANGED
@@ -3263,6 +3263,7 @@ import { join as join12 } from "node:path";
3263
3263
  import { chmodSync as chmodSync3, existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
3264
3264
  import { homedir as homedir2 } from "node:os";
3265
3265
  import { join as join8 } from "node:path";
3266
+ var DEFAULT_OPENAI = { url: "http://localhost:20128/v1", model: "auto", apiKey: null, vision: false };
3266
3267
  var DEFAULT_CLAUDE_CODE = { model: "sonnet", command: "claude", fallback: "ollama" };
3267
3268
  function sanitizeClaudeCode(raw) {
3268
3269
  const r = raw && typeof raw === "object" ? raw : {};
@@ -3351,6 +3352,7 @@ var defaults = (dataDir) => ({
3351
3352
  apiKey: null,
3352
3353
  provider: "claude",
3353
3354
  ollama: { ...DEFAULT_OLLAMA },
3355
+ openai: { ...DEFAULT_OPENAI },
3354
3356
  claudeCode: { ...DEFAULT_CLAUDE_CODE },
3355
3357
  voice: { ...DEFAULT_VOICE },
3356
3358
  view: "companion",
@@ -3386,6 +3388,7 @@ function loadConfig(dataDir = defaultDataDir()) {
3386
3388
  https: stored.https === true,
3387
3389
  destructivePatterns,
3388
3390
  ollama: { ...DEFAULT_OLLAMA, ...stored.ollama },
3391
+ openai: { ...DEFAULT_OPENAI, ...stored.openai },
3389
3392
  claudeCode: sanitizeClaudeCode(stored.claudeCode),
3390
3393
  voice: sanitizeVoice({ ...DEFAULT_VOICE, ...stored.voice }),
3391
3394
  view: stored.view === "minimal" || stored.view === "workspace" ? stored.view : "companion",
@@ -4991,7 +4994,7 @@ import { z as z11 } from "zod";
4991
4994
  var word = /^[\w.:/\\-]+$/;
4992
4995
  var modelName = z11.string().trim().min(1).max(100).regex(/^[A-Za-z0-9][\w.:/-]*$/, "A model name has no spaces");
4993
4996
  var SettingsPatch = z11.object({
4994
- provider: z11.enum(["claude", "claude-code", "ollama"]),
4997
+ provider: z11.enum(["claude", "claude-code", "ollama", "openai"]),
4995
4998
  model: modelName,
4996
4999
  /** Empty means "leave the saved key alone". */
4997
5000
  apiKey: z11.string().trim().max(400),
@@ -5013,6 +5016,13 @@ var SettingsPatch = z11.object({
5013
5016
  vision: z11.boolean(),
5014
5017
  contextTokens: z11.number().int().min(1024).max(131072)
5015
5018
  }).partial().strict(),
5019
+ openai: z11.object({
5020
+ url: z11.string().trim().max(300),
5021
+ model: modelName,
5022
+ /** Empty means "leave the saved key alone". */
5023
+ apiKey: z11.string().trim().max(400),
5024
+ vision: z11.boolean()
5025
+ }).partial().strict(),
5016
5026
  claudeCode: z11.object({
5017
5027
  model: modelName,
5018
5028
  command: z11.string().trim().min(1).max(260).regex(word, "The command is one word or path without spaces"),
@@ -5036,6 +5046,7 @@ function settingsView(c) {
5036
5046
  view: c.view,
5037
5047
  look: { ...c.look },
5038
5048
  ollama: { url: c.ollama.url, model: c.ollama.model, vision: !!c.ollama.vision, contextTokens: c.ollama.contextTokens },
5049
+ openai: { url: c.openai.url, model: c.openai.model, vision: !!c.openai.vision, apiKeySet: !!c.openai.apiKey },
5039
5050
  claudeCode: { ...c.claudeCode },
5040
5051
  voice: {
5041
5052
  enabled: c.voice.enabled,
@@ -5053,10 +5064,11 @@ var SettingsError = class extends Error {
5053
5064
  function refuseForAgent(raw) {
5054
5065
  const r = raw && typeof raw === "object" ? raw : {};
5055
5066
  const cc = r.claudeCode && typeof r.claudeCode === "object" ? r.claudeCode : {};
5056
- if ("apiKey" in r) throw new SettingsError("The API key can only be changed by the owner on the settings screen.");
5067
+ const oa = r.openai && typeof r.openai === "object" ? r.openai : {};
5068
+ if ("apiKey" in r || "apiKey" in oa) throw new SettingsError("The API key can only be changed by the owner on the settings screen.");
5057
5069
  if ("command" in cc) throw new SettingsError("The Claude Code command can only be changed by the owner on the settings screen.");
5058
5070
  }
5059
- var PROVIDER_KEYS = /* @__PURE__ */ new Set(["provider", "model", "apiKey", "ollama", "claudeCode"]);
5071
+ var PROVIDER_KEYS = /* @__PURE__ */ new Set(["provider", "model", "apiKey", "ollama", "openai", "claudeCode"]);
5060
5072
  var RESTART_VOICE = ["enabled", "sttModel"];
5061
5073
  function applySettings(config, raw) {
5062
5074
  const parsed = SettingsPatch.safeParse(raw);
@@ -5076,6 +5088,17 @@ function applySettings(config, raw) {
5076
5088
  if (u.protocol !== "http:" && u.protocol !== "https:") throw new SettingsError("ollama.url: The URL must start with http:// or https://");
5077
5089
  p.ollama.url = u.origin;
5078
5090
  }
5091
+ const openaiUrl = p.openai?.url;
5092
+ if (openaiUrl !== void 0) {
5093
+ let u;
5094
+ try {
5095
+ u = new URL(openaiUrl);
5096
+ } catch {
5097
+ throw new SettingsError("openai.url: Expected a URL such as http://localhost:20128/v1");
5098
+ }
5099
+ if (u.protocol !== "http:" && u.protocol !== "https:") throw new SettingsError("openai.url: The URL must start with http:// or https://");
5100
+ p.openai.url = (u.origin + u.pathname).replace(/\/+$/, "");
5101
+ }
5079
5102
  const next = {
5080
5103
  provider: p.provider ?? config.provider,
5081
5104
  apiKey: p.apiKey ? p.apiKey : config.apiKey,
@@ -5098,6 +5121,11 @@ function applySettings(config, raw) {
5098
5121
  if (p.model) config.model = p.model;
5099
5122
  if (p.apiKey) config.apiKey = p.apiKey;
5100
5123
  if (p.ollama) Object.assign(config.ollama, p.ollama);
5124
+ if (p.openai) {
5125
+ const { apiKey, ...rest } = p.openai;
5126
+ Object.assign(config.openai, rest);
5127
+ if (apiKey) config.openai.apiKey = apiKey;
5128
+ }
5101
5129
  if (p.claudeCode) Object.assign(config.claudeCode, p.claudeCode);
5102
5130
  if (p.voice) {
5103
5131
  const safe = next.voice;
@@ -5282,6 +5310,173 @@ var OllamaProvider = class {
5282
5310
  }
5283
5311
  };
5284
5312
 
5313
+ // ../server/src/provider/openai.ts
5314
+ import { Agent as Agent3, fetch as undiciFetch2 } from "undici";
5315
+ var encodeName3 = (n) => n.replaceAll(".", "__");
5316
+ var decodeName3 = (n) => n.replaceAll("__", ".");
5317
+ function toOpenAiMessages(system, messages, vision) {
5318
+ const out = [{ role: "system", content: system }];
5319
+ for (const m of messages) {
5320
+ if (m.role === "user") {
5321
+ out.push({ role: "user", content: m.content.map((b) => b.type === "text" ? b.text : "").join("\n") });
5322
+ } else if (m.role === "assistant") {
5323
+ const text2 = m.content.map((b) => b.type === "text" ? b.text : "").join("");
5324
+ const calls = m.content.filter((b) => b.type === "tool_use");
5325
+ out.push({
5326
+ role: "assistant",
5327
+ content: text2 || null,
5328
+ ...calls.length ? { tool_calls: calls.map((c) => ({ id: c.id, type: "function", function: { name: encodeName3(c.name), arguments: JSON.stringify(c.input ?? {}) } })) } : {}
5329
+ });
5330
+ } else {
5331
+ for (const b of m.content) if (b.type === "tool_result") out.push({ role: "tool", tool_call_id: b.toolUseId, content: b.content });
5332
+ const pictures = vision ? m.content.flatMap((b) => b.type === "image" ? [`data:${b.mime};base64,${b.data}`] : []) : [];
5333
+ if (pictures.length) out.push({ role: "user", content: [{ type: "text", text: "Here is the picture from the camera." }, ...pictures.map((url) => ({ type: "image_url", image_url: { url } }))] });
5334
+ }
5335
+ }
5336
+ return out;
5337
+ }
5338
+ function parseArgs2(raw) {
5339
+ if (!raw.trim()) return {};
5340
+ try {
5341
+ return JSON.parse(raw);
5342
+ } catch {
5343
+ return {};
5344
+ }
5345
+ }
5346
+ var OpenAiProvider = class {
5347
+ constructor(opts) {
5348
+ this.opts = opts;
5349
+ const limit = opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : 0;
5350
+ this.agent = new Agent3({ headersTimeout: limit, bodyTimeout: limit });
5351
+ }
5352
+ opts;
5353
+ id = "openai";
5354
+ agent;
5355
+ get vision() {
5356
+ return this.opts.vision === true;
5357
+ }
5358
+ async *stream(req) {
5359
+ const base = this.opts.url.replace(/\/+$/, "");
5360
+ const body = {
5361
+ model: this.opts.model,
5362
+ stream: true,
5363
+ stream_options: { include_usage: true },
5364
+ messages: [
5365
+ ...toOpenAiMessages(req.system, req.messages, this.vision),
5366
+ // last of all, so every message before it stays identical between requests and a gateway can reuse its cache of them
5367
+ ...req.context ? [{ role: "user", content: req.context }] : []
5368
+ ],
5369
+ ...req.tools.length ? { tools: req.tools.map((t) => ({ type: "function", function: { name: encodeName3(t.name), description: t.description, parameters: t.inputSchema } })) } : {}
5370
+ };
5371
+ let res;
5372
+ try {
5373
+ res = await undiciFetch2(`${base}/chat/completions`, {
5374
+ method: "POST",
5375
+ headers: { "content-type": "application/json", ...this.opts.apiKey ? { authorization: `Bearer ${this.opts.apiKey}` } : {} },
5376
+ body: JSON.stringify(body),
5377
+ signal: req.signal,
5378
+ dispatcher: this.agent
5379
+ });
5380
+ } catch (e) {
5381
+ if (req.signal.aborted) return;
5382
+ const code = e.cause?.code ?? "";
5383
+ if (code === "UND_ERR_HEADERS_TIMEOUT" || code === "UND_ERR_BODY_TIMEOUT") {
5384
+ yield { type: "error", userFacing: true, message: "The model took too long to answer. Try again, or choose a faster model." };
5385
+ return;
5386
+ }
5387
+ yield { type: "error", userFacing: true, message: `I can't reach the model server at ${base}. Is it running? Check the address in Settings.` };
5388
+ return;
5389
+ }
5390
+ if (!res.ok) {
5391
+ let detail = "";
5392
+ try {
5393
+ const j = await res.json();
5394
+ detail = typeof j.error === "string" ? j.error : j.error?.message ?? "";
5395
+ } catch {
5396
+ }
5397
+ if (res.status === 401 || res.status === 403) yield { type: "error", userFacing: true, message: "The model server refused the API key. Check it in Settings." };
5398
+ else if (res.status === 404) yield { type: "error", userFacing: true, message: `The model server has no "${this.opts.model}", or the address is wrong (it should end in /v1). ${detail}`.trim() };
5399
+ else if (res.status === 429) yield { type: "error", userFacing: true, message: "The model server says too many requests or the limit is used up. Try again in a moment." };
5400
+ else yield { type: "error", message: `Model server error (${res.status})${detail ? `: ${detail}` : ""}` };
5401
+ return;
5402
+ }
5403
+ const reader = res.body.getReader();
5404
+ const decoder = new TextDecoder();
5405
+ let buffer = "";
5406
+ let finished = false;
5407
+ const calls = /* @__PURE__ */ new Map();
5408
+ let usage = null;
5409
+ const flush = function* () {
5410
+ for (const [, c] of [...calls.entries()].sort((a, b) => a[0] - b[0])) {
5411
+ if (c.name) yield { type: "tool_call", id: c.id || `openai-${Date.now().toString(36)}-${c.name}`, name: decodeName3(c.name), args: parseArgs2(c.args) };
5412
+ }
5413
+ calls.clear();
5414
+ };
5415
+ const handle = function* (line) {
5416
+ const text2 = line.trim();
5417
+ if (!text2.startsWith("data:")) return;
5418
+ const data = text2.slice(5).trim();
5419
+ if (data === "[DONE]") {
5420
+ yield* flush();
5421
+ if (usage) yield { type: "usage", ...usage };
5422
+ yield { type: "done" };
5423
+ finished = true;
5424
+ return;
5425
+ }
5426
+ let obj;
5427
+ try {
5428
+ obj = JSON.parse(data);
5429
+ } catch {
5430
+ return;
5431
+ }
5432
+ if (obj.error) {
5433
+ yield { type: "error", message: `Model server error: ${typeof obj.error === "string" ? obj.error : obj.error.message ?? "unknown"}` };
5434
+ finished = true;
5435
+ return;
5436
+ }
5437
+ if (obj.usage) usage = { input: obj.usage.prompt_tokens ?? 0, output: obj.usage.completion_tokens ?? 0 };
5438
+ for (const choice of obj.choices ?? []) {
5439
+ const delta = choice.delta ?? {};
5440
+ if (typeof delta.content === "string" && delta.content) yield { type: "text_delta", text: delta.content };
5441
+ for (const tc of delta.tool_calls ?? []) {
5442
+ const i = typeof tc.index === "number" ? tc.index : 0;
5443
+ const cur = calls.get(i) ?? { id: "", name: "", args: "" };
5444
+ if (tc.id) cur.id = tc.id;
5445
+ if (tc.function?.name) cur.name += tc.function.name;
5446
+ if (typeof tc.function?.arguments === "string") cur.args += tc.function.arguments;
5447
+ calls.set(i, cur);
5448
+ }
5449
+ }
5450
+ };
5451
+ try {
5452
+ while (!finished) {
5453
+ const { value, done } = await reader.read();
5454
+ if (done) break;
5455
+ buffer += decoder.decode(value, { stream: true });
5456
+ let nl;
5457
+ while (!finished && (nl = buffer.indexOf("\n")) >= 0) {
5458
+ const line = buffer.slice(0, nl);
5459
+ buffer = buffer.slice(nl + 1);
5460
+ yield* handle(line);
5461
+ }
5462
+ }
5463
+ if (!finished && buffer.trim()) yield* handle(buffer);
5464
+ if (!finished) {
5465
+ yield* flush();
5466
+ const u = usage;
5467
+ if (u) yield { type: "usage", input: u.input, output: u.output };
5468
+ yield { type: "done" };
5469
+ }
5470
+ } catch (e) {
5471
+ if (req.signal.aborted) return;
5472
+ yield { type: "error", message: `Lost the connection to the model server: ${e.message}` };
5473
+ } finally {
5474
+ reader.cancel().catch(() => {
5475
+ });
5476
+ }
5477
+ }
5478
+ };
5479
+
5285
5480
  // ../server/src/tls.ts
5286
5481
  import { chmodSync as chmodSync5, existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync5, writeFileSync as writeFileSync6 } from "node:fs";
5287
5482
  import { networkInterfaces } from "node:os";
@@ -5826,6 +6021,7 @@ async function createApp(opts) {
5826
6021
  return config.claudeCode.fallback === "ollama" ? new FallbackProvider(cc, new OllamaProvider({ ...config.ollama }), (reason) => tellFallback(reason)) : cc;
5827
6022
  }
5828
6023
  if (config.provider === "ollama") return new OllamaProvider({ ...config.ollama });
6024
+ if (config.provider === "openai") return new OpenAiProvider({ ...config.openai });
5829
6025
  return config.apiKey ? new ClaudeProvider({ apiKey: config.apiKey, model: config.model }) : new MissingKeyProvider();
5830
6026
  };
5831
6027
  const switchable = opts.provider ? null : new SwitchableProvider(makeProvider());
@@ -6529,7 +6725,7 @@ var PARSERS = {
6529
6725
  return n;
6530
6726
  },
6531
6727
  provider: (v) => {
6532
- if (v !== "claude" && v !== "ollama" && v !== "claude-code") throw new Error("Provider must be claude, ollama or claude-code");
6728
+ if (v !== "claude" && v !== "ollama" && v !== "claude-code" && v !== "openai") throw new Error("Provider must be claude, ollama, claude-code or openai");
6533
6729
  return v;
6534
6730
  },
6535
6731
  roots: list,
@@ -6558,6 +6754,24 @@ var GROUPS = {
6558
6754
  return v;
6559
6755
  }
6560
6756
  },
6757
+ openai: {
6758
+ url: (v) => {
6759
+ let u;
6760
+ try {
6761
+ u = new URL(v.trim());
6762
+ } catch {
6763
+ throw new Error("Expected a URL that ends in /v1, such as http://localhost:20128/v1");
6764
+ }
6765
+ if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error("The URL must start with http:// or https://");
6766
+ return (u.origin + u.pathname).replace(/\/+$/, "");
6767
+ },
6768
+ model: (v) => {
6769
+ if (!/^[A-Za-z0-9][\w.:/-]*$/.test(v.trim())) throw new Error("Expected a model name without spaces, such as auto");
6770
+ return v.trim();
6771
+ },
6772
+ apiKey: (v) => v.trim() === "" ? null : v.trim(),
6773
+ vision: bool
6774
+ },
6561
6775
  ollama: {
6562
6776
  url: httpUrl,
6563
6777
  model: nonEmpty,
@@ -6572,7 +6786,7 @@ var GROUPS = {
6572
6786
  var KEY_HELP = {
6573
6787
  apiKey: 'Anthropic API key, used when the model is "claude"',
6574
6788
  model: "Claude model name, used with the API key",
6575
- provider: "which model answers: claude (API key), claude-code (your Claude subscription) or ollama (on this PC)",
6789
+ provider: "which model answers: claude (API key), claude-code (your Claude subscription), ollama (on this PC) or openai (any OpenAI-compatible server, such as OmniRoute)",
6576
6790
  bind: "address Friend listens on: 127.0.0.1 is this PC only, 0.0.0.0 is your whole network (easier: friend lan on)",
6577
6791
  port: "the web port (easier: friend lan port N)",
6578
6792
  https: "true serves a secure https:// address (Friend makes its own certificate), so a phone may use the microphone (easier: friend lan https on)",
@@ -6592,6 +6806,10 @@ var KEY_HELP = {
6592
6806
  "ollama.model": "the local model, for example qwen2.5:7b",
6593
6807
  "ollama.vision": "true or false: the local model can look at pictures",
6594
6808
  "ollama.contextTokens": "how much the local model remembers at once (1024 to 131072)",
6809
+ "openai.url": "address of an OpenAI-compatible server, ending in /v1; OmniRoute is http://localhost:20128/v1",
6810
+ "openai.model": "the model name that server knows; OmniRoute answers to auto",
6811
+ "openai.apiKey": "the server's API key, or empty when it asks for none (stored in your private config file)",
6812
+ "openai.vision": "true or false: that model can look at pictures",
6595
6813
  "claudeCode.model": "model for your Claude subscription: sonnet, haiku, opus or a full name",
6596
6814
  "claudeCode.command": "the command that starts Claude Code, if it is not on the PATH",
6597
6815
  "claudeCode.fallback": "ollama or none: what to use when Claude is unavailable"
@@ -8348,7 +8566,7 @@ async function model(c) {
8348
8566
  io2.out("No model is ready yet. Finish one of: `friend claude setup --use`, `friend ollama setup --use`, or `friend config set apiKey <key>`.");
8349
8567
  return "later";
8350
8568
  }
8351
- io2.out(`Friend will think with: ${cfg.provider === "claude-code" ? `Claude (${cfg.claudeCode.model}) through your subscription${cfg.claudeCode.fallback === "ollama" ? ", with the local model as a safety net" : ""}` : cfg.provider === "claude" ? "Claude with your API key" : `${cfg.ollama.model} on this PC`}.`);
8569
+ io2.out(`Friend will think with: ${cfg.provider === "claude-code" ? `Claude (${cfg.claudeCode.model}) through your subscription${cfg.claudeCode.fallback === "ollama" ? ", with the local model as a safety net" : ""}` : cfg.provider === "claude" ? "Claude with your API key" : cfg.provider === "openai" ? `${cfg.openai.model} through ${cfg.openai.url}` : `${cfg.ollama.model} on this PC`}.`);
8352
8570
  return "done";
8353
8571
  }
8354
8572
  async function voice(c) {
@@ -8463,7 +8681,7 @@ function summary(cfg, c) {
8463
8681
  }
8464
8682
  return [
8465
8683
  ` Assistant: ${owner}`,
8466
- ` Thinks with: ${cfg.provider === "claude-code" ? `Claude (${cfg.claudeCode.model}), your subscription` : cfg.provider === "claude" ? cfg.apiKey ? "Claude, API key" : "Claude, API key missing" : `${cfg.ollama.model} on this PC`}`,
8684
+ ` Thinks with: ${cfg.provider === "claude-code" ? `Claude (${cfg.claudeCode.model}), your subscription` : cfg.provider === "claude" ? cfg.apiKey ? "Claude, API key" : "Claude, API key missing" : cfg.provider === "openai" ? `${cfg.openai.model} through ${cfg.openai.url}` : `${cfg.ollama.model} on this PC`}`,
8467
8685
  ` Voice: ${has(c, "voice") ? voiceLabel(cfg.voice.ttsVoice) : "off"}`,
8468
8686
  ` Workspace: ${cfg.roots[0] ?? "none (every file action asks)"}`,
8469
8687
  ` Address: ${u.local}${u.network.length ? ` and ${u.network.join(", ")}` : ""}`,
@@ -8874,7 +9092,7 @@ var USAGE4 = `Usage: friend <command>
8874
9092
  node --url <ws-url> --code <code> [--roots a,b] [--name pc]
8875
9093
  connect this PC to a Friend gateway
8876
9094
  ollama setup|status use a local model through Ollama (no API key needed)
8877
- lan status|on|off|port N let other devices on your network open Friend, or change the port (restart to apply)
9095
+ lan status|on|off|port N|https on|off let other devices on your network open Friend, change the port, or turn on the secure (https) address a phone needs for the microphone (restart to apply)
8878
9096
  claude setup|status use your Claude subscription through Claude Code (no API key needed)
8879
9097
  voice setup|list|status|forget install local speech and choose a female or male voice; check it; forget deletes your saved voice
8880
9098
  memory list|forget <n>|clear see and delete the notes Friend keeps about you
@@ -8908,10 +9126,15 @@ async function run(argv, io2) {
8908
9126
  return await onboardCommand(rest, io2);
8909
9127
  case "doctor":
8910
9128
  return await doctorCommand(io2);
9129
+ case "help":
9130
+ case "--help":
9131
+ case "-h":
9132
+ io2.out(USAGE4);
9133
+ return 0;
8911
9134
  case "version":
8912
9135
  case "--version":
8913
9136
  case "-v":
8914
- io2.out(true ? "0.1.3" : "development");
9137
+ io2.out(true ? "0.1.5" : "development");
8915
9138
  return 0;
8916
9139
  case "lan":
8917
9140
  return await lanCommand(rest, io2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "friend-assistant",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "A self-hosted personal AI assistant for your own PC: a full-screen stage, voice, tools with your approval, and your Claude subscription or a local model.",
5
5
  "keywords": ["ai", "assistant", "jarvis", "voice", "claude", "ollama", "mcp", "self-hosted"],
6
6
  "license": "UNLICENSED",