symbiot 0.55.2 → 0.55.3
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/README.md +14 -3
- package/ai.mjs +79 -7
- package/home.mjs +2 -2
- package/index.mjs +17 -9
- package/mind.mjs +17 -4
- package/package.json +2 -2
- package/server.mjs +1 -1
- package/ui.mjs +10 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
|
|
4
4
|
|
|
5
|
+
## 0.55.3
|
|
6
|
+
|
|
7
|
+
- **Your Claude subscription, no API key.** With Claude Code signed in to your Claude account (Pro or Max), Symbiot now answers through it: chats, write-ups, posts, screenshots. Nothing to connect, no separate bill. It comes first when you haven't picked an AI, ahead of an old saved key. `symbiot login` and Settings → Your AI offer it first; if Claude Code isn't signed in yet, they tell you to run `claude` once and sign in. Before, a fresh install sent you to `symbiot login`, which only asked for an API key.
|
|
8
|
+
- **Home remembers the conversation you're having.** Home's chat didn't pass its own last turns to the AI, only what was said elsewhere in the app, so a "yeah" to its own "want that?" was read as an answer to something else and the task you'd agreed to was never made. Home now keeps its own thread: the last 16 turns, each in full, from the last 12 hours. A short answer (yes, yeah, ok, do it, 2) always answers what it just said there.
|
|
9
|
+
- **Ideas get hashed out first.** Bring Symbiot an idea and it sharpens it with you (what's strong, where it's weak, what's missing) before proposing a task, and files it once you agree.
|
|
10
|
+
- **An ambiguous ask gets readings, not a guess.** When a request could mean two things (which project, how big, what done looks like), Symbiot gives two or three numbered readings, its pick first, and asks which. Clear asks still go straight through.
|
|
11
|
+
|
|
5
12
|
## 0.55.2 — 2026-10-07
|
|
6
13
|
|
|
7
14
|
- Fixed a feature description being read as something waiting on you: an agent's "lit amber when something needs you: work waiting for your OK" showed as a Marketing lane step only you could do. A "when / if / once / until" before the words now means it describes behaviour.
|
package/README.md
CHANGED
|
@@ -40,6 +40,9 @@ agent and brings its work back to you to approve.
|
|
|
40
40
|
npm install -g symbiot # or run without installing: npx symbiot week
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
With Claude Code signed in to your Claude account, that's all: Symbiot uses your
|
|
44
|
+
subscription, no API key. See [Connect it (once)](#connect-it-once) for the other options.
|
|
45
|
+
|
|
43
46
|
## Prefer a window? `symbiot app`
|
|
44
47
|
|
|
45
48
|
```bash
|
|
@@ -911,18 +914,26 @@ Every brief also tells the agent what else it can leave in `.symbiot/`:
|
|
|
911
914
|
|
|
912
915
|
## Connect it (once)
|
|
913
916
|
|
|
914
|
-
|
|
917
|
+
**Have a Claude subscription (Pro or Max)? There's nothing to connect.** If
|
|
918
|
+
[Claude Code](https://claude.com/claude-code) is installed and signed in with your
|
|
919
|
+
Claude account (run `claude` once and sign in), Symbiot answers through it: no API
|
|
920
|
+
key, no separate bill. It runs Claude Code headless with Symbiot's own instructions,
|
|
921
|
+
no tools (Read only, for a screenshot you attach), none of your settings, plugins or
|
|
922
|
+
servers, and no session kept.
|
|
923
|
+
|
|
924
|
+
Otherwise, pick the AI you want it to write with:
|
|
915
925
|
|
|
916
926
|
```bash
|
|
917
|
-
symbiot login #
|
|
927
|
+
symbiot login # your Claude subscription, Claude (API key), OpenAI, Gemini, or a local model (Ollama)
|
|
918
928
|
```
|
|
919
929
|
|
|
920
|
-
You'll get a short menu; paste
|
|
930
|
+
You'll get a short menu; for a hosted model paste its API key (for Ollama, just point
|
|
921
931
|
it at your local server) and it's saved to `~/.config/symbiot/config.json`. The
|
|
922
932
|
app's Settings tab does the same.
|
|
923
933
|
|
|
924
934
|
| Provider | Get a key | Default model |
|
|
925
935
|
|----------|-----------|---------------|
|
|
936
|
+
| Your Claude subscription (Claude Code) | no key: sign in to Claude Code | your plan's default |
|
|
926
937
|
| Claude (Anthropic) | <https://console.anthropic.com/settings/keys> | `claude-opus-5-5` |
|
|
927
938
|
| OpenAI (GPT) | <https://platform.openai.com/api-keys> | `gpt-4o-mini` |
|
|
928
939
|
| Gemini (Google) | <https://aistudio.google.com/apikey> | `gemini-1.5-flash` |
|
package/ai.mjs
CHANGED
|
@@ -1,23 +1,52 @@
|
|
|
1
1
|
// The AI Symbiot writes with: the providers, which one is set up, one call per
|
|
2
2
|
// provider (same in, same out), and the local-model (Ollama) setup.
|
|
3
3
|
import Anthropic from "@anthropic-ai/sdk";
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
5
|
import { homedir, totalmem, cpus as oscpus } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { existsSync } from "node:fs";
|
|
8
|
-
import { loadConfig, saveConfig, sh } from "./core.mjs";
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { loadConfig, saveConfig, sh, hasCmd, CONFIG_DIR } from "./core.mjs";
|
|
9
9
|
|
|
10
10
|
const MAX_TOKENS = 1600;
|
|
11
11
|
|
|
12
12
|
// The providers Symbiot can write with. Models are sensible defaults; override
|
|
13
13
|
// per provider at login, or globally with SYMBIOT_MODEL.
|
|
14
14
|
const PROVIDERS = {
|
|
15
|
+
// your Claude subscription (Pro or Max), through Claude Code signed in on this computer: no key
|
|
16
|
+
claude: { label: "Your Claude subscription (Claude Code)", sub: true, keyUrl: "https://claude.com/claude-code", keyName: null, model: "" },
|
|
15
17
|
anthropic: { label: "Claude (Anthropic)", env: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"], keyUrl: "https://console.anthropic.com/settings/keys", keyName: "Anthropic API key (sk-ant-…)", model: "claude-opus-5-5" },
|
|
16
18
|
openai: { label: "OpenAI (GPT)", env: ["OPENAI_API_KEY"], keyUrl: "https://platform.openai.com/api-keys", keyName: "OpenAI API key (sk-…)", model: "gpt-4o-mini" },
|
|
17
19
|
gemini: { label: "Gemini (Google)", env: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], keyUrl: "https://aistudio.google.com/apikey", keyName: "Google AI API key", model: "gemini-1.5-flash" },
|
|
18
20
|
ollama: { label: "Local model (Ollama)", local: true, keyUrl: "https://ollama.com", keyName: null, model: "llama3.1" },
|
|
19
21
|
};
|
|
20
22
|
|
|
23
|
+
// ---- your Claude subscription -------------------------------------------------
|
|
24
|
+
// Claude Code signed in with a Claude account (Pro or Max) answers with that account,
|
|
25
|
+
// no API key: Symbiot runs it headless (claude -p), the way it already runs your
|
|
26
|
+
// agents. Whether it's there and signed in: `claude auth status` (a quarter of a
|
|
27
|
+
// second), kept 5 minutes. SYMBIOT_CLAUDE_CMD replaces the command (the tests' stand-in).
|
|
28
|
+
const CLAUDE_BIN = () => process.env.SYMBIOT_CLAUDE_CMD || "claude";
|
|
29
|
+
let CLAUDE_SEEN = null;
|
|
30
|
+
function claudeState(fresh = false) {
|
|
31
|
+
if (!fresh && CLAUDE_SEEN && Date.now() - CLAUDE_SEEN.at < 300000) return CLAUDE_SEEN;
|
|
32
|
+
let installed = !!process.env.SYMBIOT_CLAUDE_CMD || hasCmd("claude"), signedIn = false;
|
|
33
|
+
if (installed) {
|
|
34
|
+
try {
|
|
35
|
+
const r = spawnSync(CLAUDE_BIN(), ["auth", "status"], { encoding: "utf8", timeout: 8000, env: process.env });
|
|
36
|
+
if (r.error && r.error.code === "ENOENT") installed = false;
|
|
37
|
+
else { const j = JSON.parse(String(r.stdout || "").trim() || "{}"); signedIn = !!j.loggedIn && j.authMethod !== "apiKey"; }
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
CLAUDE_SEEN = { installed, signedIn, at: Date.now() };
|
|
41
|
+
return CLAUDE_SEEN;
|
|
42
|
+
}
|
|
43
|
+
const claudeReady = (fresh) => claudeState(fresh).signedIn;
|
|
44
|
+
// What to do when it isn't: one line, for the CLI and the app.
|
|
45
|
+
function claudeHelp(st = claudeState()) {
|
|
46
|
+
return st.installed ? "Claude Code is installed but not signed in: run claude once in a terminal and sign in with your Claude account, then try again."
|
|
47
|
+
: "Install Claude Code ( npm install -g @anthropic-ai/claude-code ), run claude once and sign in with your Claude account, then try again.";
|
|
48
|
+
}
|
|
49
|
+
|
|
21
50
|
// ---- config + provider resolution (config files: core.mjs) -----------------
|
|
22
51
|
function antProfileExists() {
|
|
23
52
|
try { return existsSync(join(homedir(), ".config", "anthropic")); } catch { return false; }
|
|
@@ -28,17 +57,22 @@ function envKey(provider) {
|
|
|
28
57
|
return null;
|
|
29
58
|
}
|
|
30
59
|
// Returns { provider, key?, baseUrl?, model, source } or null if nothing set up.
|
|
31
|
-
// Order: saved choice →
|
|
60
|
+
// Order: saved choice → your Claude subscription (Claude Code signed in) → legacy
|
|
61
|
+
// saved key → env keys → an `ant` profile. With no choice saved, the subscription
|
|
62
|
+
// comes before a key: it's what most people already pay for.
|
|
32
63
|
function resolveProvider() {
|
|
33
64
|
const cfg = loadConfig();
|
|
34
65
|
const m = process.env.SYMBIOT_MODEL;
|
|
35
|
-
|
|
66
|
+
const sub = () => ({ provider: "claude", model: m || (cfg.claude || {}).model || "", source: "your Claude subscription (Claude Code)" });
|
|
67
|
+
if (cfg.provider === "claude" && claudeReady()) return sub();
|
|
68
|
+
if (cfg.provider && cfg.provider !== "claude" && PROVIDERS[cfg.provider]) {
|
|
36
69
|
const p = cfg.provider, pc = cfg[p] || {};
|
|
37
70
|
if (p === "ollama") return { provider: p, baseUrl: pc.baseUrl || "http://localhost:11434", model: m || pc.model || PROVIDERS.ollama.model, source: "saved login" };
|
|
38
71
|
const key = pc.apiKey || envKey(p);
|
|
39
72
|
if (key || (p === "anthropic" && (process.env.ANTHROPIC_AUTH_TOKEN || antProfileExists())))
|
|
40
73
|
return { provider: p, key, model: m || pc.model || PROVIDERS[p].model, source: pc.apiKey ? "saved login" : "environment" };
|
|
41
74
|
}
|
|
75
|
+
if ((!cfg.provider || cfg.provider === "claude") && claudeReady()) return sub();
|
|
42
76
|
if (cfg.apiKey) return { provider: "anthropic", key: cfg.apiKey, model: m || PROVIDERS.anthropic.model, source: "saved login (~/.config/symbiot)" };
|
|
43
77
|
if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN) return { provider: "anthropic", key: process.env.ANTHROPIC_API_KEY, model: m || PROVIDERS.anthropic.model, source: "ANTHROPIC_* (environment)" };
|
|
44
78
|
if (process.env.OPENAI_API_KEY) return { provider: "openai", key: process.env.OPENAI_API_KEY, model: m || PROVIDERS.openai.model, source: "OPENAI_API_KEY (environment)" };
|
|
@@ -48,6 +82,36 @@ function resolveProvider() {
|
|
|
48
82
|
}
|
|
49
83
|
|
|
50
84
|
// ---- model calls (one per provider, same in/out) --------------------------
|
|
85
|
+
// Claude Code, headless, on your subscription: Symbiot's own instructions as the
|
|
86
|
+
// system prompt, the prompt on stdin, no tools (Read only, for screenshots you
|
|
87
|
+
// attached), none of your settings, plugins or servers, no session kept. Not --bare:
|
|
88
|
+
// that ignores the subscription sign-in.
|
|
89
|
+
function callClaude(r, system, prompt, images = []) {
|
|
90
|
+
const shots = [];
|
|
91
|
+
for (const i of images) {
|
|
92
|
+
if (i.path) { shots.push(i.path); continue; }
|
|
93
|
+
try { const dir = join(CONFIG_DIR, "uploads"); mkdirSync(dir, { recursive: true, mode: 0o700 }); const f = join(dir, `${Date.now()}-${shots.length}.${/png/.test(i.mime) ? "png" : /webp/.test(i.mime) ? "webp" : /gif/.test(i.mime) ? "gif" : "jpg"}`); writeFileSync(f, Buffer.from(i.data, "base64"), { mode: 0o600 }); shots.push(f); } catch {}
|
|
94
|
+
}
|
|
95
|
+
const input = shots.length ? `${prompt}\n\nScreenshots they attached (look at them with your Read tool): ${shots.join(", ")}` : prompt;
|
|
96
|
+
const args = ["-p", "--output-format", "json", "--system-prompt", system, "--tools", shots.length ? "Read" : "", "--setting-sources", "",
|
|
97
|
+
"--strict-mcp-config", "--no-session-persistence", "--disable-slash-commands", ...(r.model ? ["--model", r.model] : [])];
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
let out = "", err = "", done = false;
|
|
100
|
+
let ch; try { ch = spawn(CLAUDE_BIN(), args, { cwd: CONFIG_DIR, stdio: ["pipe", "pipe", "pipe"], env: process.env }); } catch (e) { reject(e); return; }
|
|
101
|
+
const t = setTimeout(() => { if (!done) { done = true; try { ch.kill("SIGTERM"); } catch {} reject(new Error("Claude Code didn't answer within 3 minutes")); } }, 180000);
|
|
102
|
+
ch.stdout.on("data", (d) => { out += d; }); ch.stderr.on("data", (d) => { err += d; });
|
|
103
|
+
ch.on("error", (e) => { if (!done) { done = true; clearTimeout(t); reject(e); } });
|
|
104
|
+
ch.on("close", () => {
|
|
105
|
+
if (done) return; done = true; clearTimeout(t);
|
|
106
|
+
let j = null; try { j = JSON.parse(out.trim().split("\n").filter(Boolean).pop() || "{}"); } catch {}
|
|
107
|
+
if (j && j.is_error === false && typeof j.result === "string") { resolve(j.result.trim()); return; }
|
|
108
|
+
const why = (j && j.result) || err.trim().split("\n").pop() || out.trim().slice(0, 200) || "no answer";
|
|
109
|
+
CLAUDE_SEEN = null; // signed out since? ask again next time
|
|
110
|
+
reject(new Error(`Claude Code: ${String(why).slice(0, 300)}`));
|
|
111
|
+
});
|
|
112
|
+
ch.stdin.end(input);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
51
115
|
// Screenshots you dropped into the talk: [{ mime, data (base64) }], in each model's own shape.
|
|
52
116
|
async function callAnthropic(r, system, prompt, images = []) {
|
|
53
117
|
const client = new Anthropic(r.key ? { apiKey: r.key } : {});
|
|
@@ -111,7 +175,7 @@ async function callOllama(r, system, prompt, images = []) {
|
|
|
111
175
|
// spinner, its coloured hints); imported on its own, it stays quiet.
|
|
112
176
|
const AI_UI = {
|
|
113
177
|
spinner: () => () => {},
|
|
114
|
-
notConnected: () => console.log("Symbiot needs an AI to write your updates.
|
|
178
|
+
notConnected: () => console.log("Symbiot needs an AI to write your updates. With a Claude subscription, sign in to Claude Code (run claude once) and Symbiot uses it, no key. Otherwise: symbiot login"),
|
|
115
179
|
rejected: (label) => console.log(`Your ${label} credentials were rejected. Reconnect with: symbiot login --force`),
|
|
116
180
|
};
|
|
117
181
|
async function write(system, prompt, { images = [] } = {}) {
|
|
@@ -119,6 +183,7 @@ async function write(system, prompt, { images = [] } = {}) {
|
|
|
119
183
|
if (!r) { AI_UI.notConnected(); return null; }
|
|
120
184
|
const stop = AI_UI.spinner("thinking…");
|
|
121
185
|
try {
|
|
186
|
+
if (r.provider === "claude") return await callClaude(r, system, prompt, images);
|
|
122
187
|
if (r.provider === "anthropic") return await callAnthropic(r, system, prompt, images);
|
|
123
188
|
if (r.provider === "openai") return await callOpenAI(r, system, prompt, images);
|
|
124
189
|
if (r.provider === "gemini") return await callGemini(r, system, prompt, images);
|
|
@@ -135,6 +200,7 @@ async function write(system, prompt, { images = [] } = {}) {
|
|
|
135
200
|
|
|
136
201
|
async function validate(provider, { key, baseUrl } = {}) {
|
|
137
202
|
try {
|
|
203
|
+
if (provider === "claude") return claudeReady(true);
|
|
138
204
|
if (provider === "anthropic") { await new Anthropic({ apiKey: key }).models.list(); return true; }
|
|
139
205
|
if (provider === "openai") return (await fetch("https://api.openai.com/v1/models", { headers: { Authorization: `Bearer ${key}` } })).ok;
|
|
140
206
|
if (provider === "gemini") return (await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(key)}`)).ok;
|
|
@@ -197,6 +263,12 @@ async function connectProvider(b) {
|
|
|
197
263
|
const provider = b && b.provider;
|
|
198
264
|
if (!PROVIDERS[provider]) return { ok: false, message: "Unknown provider." };
|
|
199
265
|
const cfg = loadConfig();
|
|
266
|
+
if (provider === "claude") {
|
|
267
|
+
if (!claudeReady(true)) return { ok: false, message: claudeHelp(claudeState()) };
|
|
268
|
+
const model = (b.model || "").trim();
|
|
269
|
+
cfg.provider = "claude"; cfg.claude = model ? { model } : {};
|
|
270
|
+
return saveConfig(cfg) ? { ok: true, message: `Connected: ${PROVIDERS.claude.label}${model ? " · " + model : ""}. No key needed.` } : { ok: false, message: "Couldn't write the config file." };
|
|
271
|
+
}
|
|
200
272
|
if (provider === "ollama") {
|
|
201
273
|
const baseUrl = (b.baseUrl || "http://localhost:11434").trim();
|
|
202
274
|
const model = (b.model || "").trim() || PROVIDERS.ollama.model;
|
|
@@ -212,4 +284,4 @@ async function connectProvider(b) {
|
|
|
212
284
|
return saveConfig(cfg) ? { ok: true, message: `Connected: ${PROVIDERS[provider].label} · ${model}` } : { ok: false, message: "Couldn't write the config file." };
|
|
213
285
|
}
|
|
214
286
|
|
|
215
|
-
export { PROVIDERS, AI_UI, resolveProvider, write, validate, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel };
|
|
287
|
+
export { PROVIDERS, AI_UI, resolveProvider, write, validate, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel , claudeState, claudeReady, claudeHelp };
|
package/home.mjs
CHANGED
|
@@ -260,7 +260,7 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
260
260
|
// What Symbiot works through (an AI, your agent, the connectors runs use) shows
|
|
261
261
|
// only when it's missing, and then first (urgent): nothing works without it.
|
|
262
262
|
// First run: what only a new user can do before the rest means anything.
|
|
263
|
-
if (!connected()) you.push({ kind: "setup", id: "setup:ai", urgent: true, title: "Connect an AI", sub: "Symbiot can't work without one: a key, or a free local model", shape: "settings", focus: "ai" });
|
|
263
|
+
if (!connected()) you.push({ kind: "setup", id: "setup:ai", urgent: true, title: "Connect an AI", sub: "Symbiot can't work without one: your Claude subscription (sign in to Claude Code), a key, or a free local model", shape: "settings", focus: "ai" });
|
|
264
264
|
const gone = agentGone(); if (gone) you.push({ kind: "setup", id: "setup:agent", urgent: true, title: "Your agent is unavailable", sub: `${gone} isn't on this computer any more, so no task can start`, shape: "settings", focus: "agent" });
|
|
265
265
|
for (const c of signedOut()) you.push({ kind: "setup", id: "setup:conn:" + c.id, urgent: true, title: `Reconnect ${c.name}`, sub: `its Claude connector is signed out, so agent runs can't use ${c.name}`, shape: "settings", focus: "agent" });
|
|
266
266
|
if (!Object.keys(map).some((n) => map[n] !== MARKETING_DIR)) you.push({ kind: "setup", id: "setup:folders", title: "Show me your work", sub: "where your repos are", shape: "settings", focus: "work" });
|
|
@@ -408,7 +408,7 @@ async function homeAsk(question, { ask, now = Date.now(), state, images = [] } =
|
|
|
408
408
|
const h = state || homeState({ now });
|
|
409
409
|
let map = {}; try { map = laneMap(); } catch {}
|
|
410
410
|
const role = "Here they're on Symbiot's home: one liquid surface that shows what only they can do, what's new on what they watch, and the lanes. Answer from what it shows, and act on what they ask.";
|
|
411
|
-
const r = await converse({ where: "Home", role, context: homeContext(h), question: question + (pics.length ? `\n(The user attached ${pics.length} screenshot${pics.length > 1 ? "s" : ""}: you can see ${pics.length > 1 ? "them" : "it"}.)` : ""), map, now, images: pics.map(({ mime, data }) => ({ mime, data })), ...(ask ? { ask } : {}),
|
|
411
|
+
const r = await converse({ where: "Home", role, context: homeContext(h), question: question + (pics.length ? `\n(The user attached ${pics.length} screenshot${pics.length > 1 ? "s" : ""}: you can see ${pics.length > 1 ? "them" : "it"}.)` : ""), map, now, images: pics.map(({ mime, data, path }) => ({ mime, data, path })), ...(ask ? { ask } : {}),
|
|
412
412
|
act: {
|
|
413
413
|
agent: (req, known, repo) => (repo ? actIn(req + shotNote, repo, { map, known, title: "Home" }) : actNow(req + shotNote, { title: "Home", known })),
|
|
414
414
|
task: (text, repo) => taskIn(text + shotNote, repo, { map }),
|
package/index.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { fileURLToPath } from "node:url";
|
|
|
32
32
|
import { EMBEDDED_UI } from "./ui.mjs";
|
|
33
33
|
import { VERSION, CONFIG_PATH, loadConfig, saveConfig, repoState, semverGt } from "./core.mjs";
|
|
34
34
|
import { HANDOFF_PROMPT, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, fillHandoff, runHandoff, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, parseQuestions, agentQuestions } from "./agents.mjs";
|
|
35
|
-
import { AI_UI, PROVIDERS, resolveProvider, validate, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
|
|
35
|
+
import { AI_UI, PROVIDERS, resolveProvider, validate, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel, claudeState, connectProvider } from "./ai.mjs";
|
|
36
36
|
import { setScanOptions, scanBase, authorship, readmeInfo, repoShape, houseRules, reportFooter, findAllRepos, buildMap } from "./scan.mjs";
|
|
37
37
|
import { gitDefaultBranch, loadDeploys, driftRepo, computeDrift } from "./drift.mjs";
|
|
38
38
|
import { buildTasksMd, taskType, shipChanges, shipWithBump, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, syncTasks, pendingReview, unreleased, publishesOnMerge, addTask, approveRepo, approveChanges, sendBack, pushTasks } from "./tasks.mjs";
|
|
@@ -118,7 +118,7 @@ function ask(question, { secret = false } = {}) {
|
|
|
118
118
|
|
|
119
119
|
const AUTH_HELP =
|
|
120
120
|
c.y("Symbiot needs an AI to write your updates. Connect one:\n") +
|
|
121
|
-
" " + c.b("symbiot login") + c.d("
|
|
121
|
+
" " + c.b("symbiot login") + c.d(" your Claude subscription (Claude Code, no key), Claude, OpenAI, Gemini or a local model") + "\n" +
|
|
122
122
|
c.d(" Or set a key in your environment: ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY.");
|
|
123
123
|
// What writing with the AI (ai.mjs) shows in the terminal.
|
|
124
124
|
Object.assign(AI_UI, {
|
|
@@ -215,26 +215,34 @@ async function cmdLogin() {
|
|
|
215
215
|
const flagProvider = flag("provider", null);
|
|
216
216
|
const existing = resolveProvider();
|
|
217
217
|
if (existing && !flagProvider && !flag("key", null) && !has("force")) {
|
|
218
|
-
console.log(c.g("✓ ") + `Already connected — ${PROVIDERS[existing.provider].label} via ${existing.source}.`);
|
|
218
|
+
console.log(c.g("✓ ") + (existing.provider === "claude" ? "Already connected: your Claude subscription, through Claude Code signed in here. No key needed." : `Already connected — ${PROVIDERS[existing.provider].label} via ${existing.source}.`));
|
|
219
219
|
console.log(c.d(" Switch or replace it with `symbiot login --force`."));
|
|
220
220
|
return;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
let provider = flagProvider;
|
|
224
224
|
if (!provider) {
|
|
225
|
+
const cs = claudeState(true);
|
|
225
226
|
console.log("\n" + c.b("Connect Symbiot") + "\n");
|
|
226
227
|
console.log("Which AI should Symbiot write your updates with?\n");
|
|
227
|
-
console.log(" 1) " + PROVIDERS.
|
|
228
|
-
console.log(" 2) " + PROVIDERS.
|
|
229
|
-
console.log(" 3) " + PROVIDERS.
|
|
230
|
-
console.log(" 4) " + PROVIDERS.
|
|
231
|
-
|
|
232
|
-
|
|
228
|
+
console.log(" 1) " + PROVIDERS.claude.label + c.d(cs.signedIn ? " — signed in here, no key" : cs.installed ? " — Claude Code is here; sign in first (run claude )" : " — needs Claude Code, signed in (no key)"));
|
|
229
|
+
console.log(" 2) " + PROVIDERS.anthropic.label + c.d(" — needs an Anthropic API key"));
|
|
230
|
+
console.log(" 3) " + PROVIDERS.openai.label + c.d(" — needs an OpenAI API key"));
|
|
231
|
+
console.log(" 4) " + PROVIDERS.gemini.label + c.d(" — needs a Google AI API key"));
|
|
232
|
+
console.log(" 5) " + PROVIDERS.ollama.label + c.d(" — runs on your machine, no key"));
|
|
233
|
+
const pick = (await ask("\nChoose 1-5 [1]: ")) || "1";
|
|
234
|
+
provider = { 1: "claude", 2: "anthropic", 3: "openai", 4: "gemini", 5: "ollama" }[pick] || (PROVIDERS[pick] ? pick : "claude");
|
|
233
235
|
}
|
|
234
236
|
if (!PROVIDERS[provider]) { console.log(c.y("Unknown provider: " + provider)); return; }
|
|
235
237
|
const meta = PROVIDERS[provider];
|
|
236
238
|
const cfg = loadConfig();
|
|
237
239
|
|
|
240
|
+
if (provider === "claude") {
|
|
241
|
+
const r = await connectProvider({ provider: "claude", model: flag("model", null) || "" });
|
|
242
|
+
if (!r.ok) { console.log(c.y(r.message)); process.exitCode = 1; return; }
|
|
243
|
+
console.log(c.g("✓ ") + r.message + " Try: " + c.b("symbiot week"));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
238
246
|
if (provider === "ollama") {
|
|
239
247
|
const baseUrl = (flag("base-url", null) || (await ask("Ollama URL [http://localhost:11434]: ")) || "").trim() || "http://localhost:11434";
|
|
240
248
|
const model = (flag("model", null) || (await ask(`Model name [${meta.model}]: `)) || "").trim() || meta.model;
|
package/mind.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { knowledgeFor } from "./knowledge.mjs";
|
|
|
32
32
|
const MIND_FILE = join(CONFIG_DIR, "mind.json");
|
|
33
33
|
const ACT_DIR = join(CONFIG_DIR, "drafts"); // runs of their own, next to drafted replies
|
|
34
34
|
const CLI = fileURLToPath(new URL("./index.mjs", import.meta.url));
|
|
35
|
-
const MAX_NODES = 400, MAX_FACTS = 12, MAX_LOG =
|
|
35
|
+
const MAX_NODES = 400, MAX_FACTS = 12, MAX_LOG = 240, RECALL = 8, LOG_LINES = 6, TURN_CHARS = 1500;
|
|
36
36
|
|
|
37
37
|
// ---- memory ---------------------------------------------------------------------
|
|
38
38
|
function loadMind() {
|
|
@@ -80,10 +80,19 @@ function recall(query, now = Date.now(), d = loadMind(), limit = RECALL) {
|
|
|
80
80
|
}
|
|
81
81
|
const recallText = (nodes) => nodes.map((n) => `- ${n.name} (${n.kind}): ${n.facts.slice(-4).map((f) => f.text).join("; ")}`).join("\n");
|
|
82
82
|
function logTurn(where, role, text, now = Date.now(), d = loadMind()) {
|
|
83
|
-
d.log = [...d.log, { ts: now, where, role, text: String(text || "").replace(/\s+/g, " ").trim().slice(0,
|
|
83
|
+
d.log = [...d.log, { ts: now, where, role, text: String(text || "").replace(/\s+/g, " ").trim().slice(0, TURN_CHARS) }].slice(-MAX_LOG);
|
|
84
84
|
}
|
|
85
85
|
// The last few lines said anywhere else in the app: how the next page knows
|
|
86
86
|
// what you were just talking about.
|
|
87
|
+
// This chat's own last turns, for a chat that doesn't keep its thread itself (Home):
|
|
88
|
+
// without them a "yeah" to "want that?" went to whatever was said elsewhere (a
|
|
89
|
+
// kernel reboot card) and the task they'd agreed to was never made. Home is where
|
|
90
|
+
// people think things through, so it keeps a real conversation: 16 turns, each whole
|
|
91
|
+
// (up to TURN_CHARS), from the last 12 hours.
|
|
92
|
+
const THREAD_TURNS = 16, THREAD_AGE = 12 * 3600000;
|
|
93
|
+
function ownThread(where, d = loadMind(), now = Date.now()) {
|
|
94
|
+
return d.log.filter((l) => l.where === where && now - (l.ts || 0) < THREAD_AGE).slice(-THREAD_TURNS).map((l) => `${l.role === "user" ? "User" : "You"}: ${l.text}`).join("\n");
|
|
95
|
+
}
|
|
87
96
|
function lately(where, d = loadMind(), n = LOG_LINES) {
|
|
88
97
|
return d.log.filter((l) => l.where !== where).slice(-n).map((l) => `- [${l.where}] ${l.role === "user" ? "User" : "You"}: ${l.text.slice(0, 200)}`).join("\n");
|
|
89
98
|
}
|
|
@@ -156,6 +165,9 @@ function rulesFor(lanes = [], self = "") {
|
|
|
156
165
|
When they ask you to do something (look into it, find out, fix, set up, sign in, close, send, chase), do it, don't explain what you can't do: "agent" starts a coding agent now, with their tools and connectors (MCP, the command line, a browser signed in to their linked sites). With a "repo", it's that project's agent, working in it; empty, an agent for everything outside a repo (this computer, accounts, services). Use "task" for what's for later. Don't ask their permission to hand it over: the agent asks them first, on the Workdesk, before anything hard to undo (closing an account, deleting, paying, sending); say so in your reply when it applies, with what to check first. A question you can answer from what's here: answer it, "do": null.
|
|
157
166
|
Lanes ("repo" is one of these, exactly): ${lanes.length ? lanes.slice(0, 60).join(", ") : "(none found)"}.${lanes.includes(MARKETING) ? ` "${MARKETING}" is a lane of its own for marketing any of their products (posts, demo videos, launches, campaigns, pricing pages' copy): marketing work goes there, starting with the product it's for in brackets, "[Dailify] a launch post".` : ""}${self ? ` Symbiot itself is "${self}": a flaw in how Symbiot works (how it read a page, what a brief or a card said, anything in the app) goes there.` : ""}
|
|
158
167
|
Don't wait to be asked to fix Symbiot. When you notice it got something wrong (you misread a page, a brief or a card misled them, a step made them do an agent's job), say so plainly and, in the same reply, start the fix: "agent"${self ? ` with "repo": "${self}"` : ""}, saying what went wrong, an example, and what it should do instead.
|
|
168
|
+
A short answer (yes, yeah, ok, sure, go, do it, that one, 2) answers what you last said in "This chat so far", never something from elsewhere in the app. If you'd proposed a task or an agent and they agree, do it now in "do", in the lane you named.
|
|
169
|
+
Hash it out first: when they bring an idea rather than a clear ask, sharpen it with them: say what's strong, push back where it's weak, add what's missing, and propose it as a task (naming the lane) once it holds up. "do" stays null until they agree.
|
|
170
|
+
When an ask could mean two or more things (which lane, how much, what done looks like), don't guess: "do" stays null, and the reply gives 2-3 short numbered readings, your pick first, and asks which. A clear ask goes straight through.
|
|
159
171
|
"remember": only lasting facts worth knowing on another page (who someone is, which account is what, a decision, how they like things); [] for anything else.`;
|
|
160
172
|
}
|
|
161
173
|
// Symbiot's own repo among your lanes (its package.json is symbiot's), or "".
|
|
@@ -193,6 +205,7 @@ function parseReply(raw) {
|
|
|
193
205
|
// what "do" runs here. Gives { reply, did?, remembered }.
|
|
194
206
|
async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, map = null, now = Date.now(), images = [] }) {
|
|
195
207
|
const d = loadMind(), hits = recall(question, now, d), known = recallText(hits), elsewhere = lately(where, d);
|
|
208
|
+
if (!history) history = ownThread(where, d, now);
|
|
196
209
|
// what it did to answer, to show under the reply (as the app shows an agent's work)
|
|
197
210
|
const steps = [];
|
|
198
211
|
if (hits.length) steps.push(`recalled ${hits.length === 1 ? hits[0].name : hits.length + " things: " + hits.slice(0, 3).map((h) => h.name).join(", ")}`);
|
|
@@ -205,7 +218,7 @@ async function converse({ where, role = "", context = "", history = "", question
|
|
|
205
218
|
// how they talk, from what they've typed into any chat (adapt.mjs: accommodation)
|
|
206
219
|
const voice = styleLine(styleOf(d.log.filter((l) => l.role === "user").map((l) => l.text).concat(question)));
|
|
207
220
|
const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes))}${voice ? "\n" + voice : ""}`;
|
|
208
|
-
const prompt = (known ? `What you know (from across the app):\n${known}\n\n` : "") + (kn.text ? kn.text + "\n\n" : "") + (elsewhere ? `Lately, elsewhere in the app:\n${elsewhere}\n\n` : "") +
|
|
221
|
+
const prompt = (known ? `What you know (from across the app):\n${known}\n\n` : "") + (kn.text ? kn.text + "\n\n" : "") + (elsewhere ? `Lately, elsewhere in the app (other chats: a short answer here doesn't reply to these):\n${elsewhere}\n\n` : "") +
|
|
209
222
|
(context ? context + "\n\n" : "") + (history ? `This chat so far:\n${history}\n\n` : "") + `They say (on ${where}): ${question}`;
|
|
210
223
|
const raw = await ask(system, prompt, images.length ? { images } : undefined);
|
|
211
224
|
if (!raw || /^\(?couldn't reach the model/i.test(String(raw))) return { reply: "(couldn't reach the model)", remembered: 0 };
|
|
@@ -237,4 +250,4 @@ async function converse({ where, role = "", context = "", history = "", question
|
|
|
237
250
|
// The usual "task" act, for a caller with no lanes of its own to pass.
|
|
238
251
|
const addToTasks = (text, repo) => { let map = {}; try { map = laneMap(); } catch {} return taskIn(text, repo, { map }); };
|
|
239
252
|
|
|
240
|
-
export { MIND_FILE, loadMind, remember, recall, recallText, lately, mindState, forget, actBrief, actNow, actIn, taskIn, selfLane, rulesFor, parseReply, converse, addToTasks };
|
|
253
|
+
export { MIND_FILE, loadMind, remember, recall, recallText, lately, mindState, forget, actBrief, actNow, actIn, taskIn, selfLane, rulesFor, parseReply, converse, addToTasks , ownThread };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.55.
|
|
3
|
+
"version": "0.55.3",
|
|
4
4
|
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"@anthropic-ai/sdk": "^0.128.0"
|
|
78
78
|
},
|
|
79
79
|
"scripts": {
|
|
80
|
-
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
80
|
+
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/subscription.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
81
81
|
"lint": "eslint ."
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
package/server.mjs
CHANGED
|
@@ -231,7 +231,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
231
231
|
if (tok !== TOKEN) { res.writeHead(403); res.end("forbidden"); return; }
|
|
232
232
|
}
|
|
233
233
|
try {
|
|
234
|
-
if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, line: `${PROVIDERS[r.provider].label} ·
|
|
234
|
+
if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, provider: r.provider, line: `${PROVIDERS[r.provider].label}${r.model ? " · " + r.model : ""}` } : { connected: false }); }
|
|
235
235
|
if (u.pathname === "/api/map") return json(res, await buildMap()); // local git only — no AI key needed
|
|
236
236
|
if (u.pathname === "/api/scan") return json(res, { active: SCAN.active, phase: SCAN.phase, done: SCAN.done, total: SCAN.total, item: SCAN.item, elapsed: SCAN.startedAt ? Date.now() - SCAN.startedAt : 0, timeout: SCAN_TIMEOUT_MS, partial: SCAN.partial });
|
|
237
237
|
if (u.pathname === "/api/models") { const hw = detectHardware(); return json(res, { hardware: hw, rec: recommendModels(hw) }); }
|
package/ui.mjs
CHANGED
|
@@ -1078,12 +1078,14 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
|
|
|
1078
1078
|
<div class="sset"><h3 class="ssh">Your AI</h3>
|
|
1079
1079
|
<label>Which AI should Symbiot write with?</label>
|
|
1080
1080
|
<select id="provider">
|
|
1081
|
-
<option value="
|
|
1081
|
+
<option value="claude">Your Claude subscription (Claude Code) - no key</option>
|
|
1082
|
+
<option value="anthropic">Claude (Anthropic) - API key</option>
|
|
1082
1083
|
<option value="openai">OpenAI (GPT)</option>
|
|
1083
1084
|
<option value="gemini">Gemini (Google)</option>
|
|
1084
1085
|
<option value="ollama">Local model (Ollama) - free, no key</option>
|
|
1085
1086
|
</select>
|
|
1086
|
-
<div class="note muted">
|
|
1087
|
+
<div class="note muted">With a Claude subscription (Pro or Max), Symbiot answers through <b>Claude Code</b> signed in on this computer: no key, no extra bill. Otherwise a hosted model needs an API key, or run <b>Ollama</b> locally for a free, private option (nothing leaves your machine).</div>
|
|
1088
|
+
<div id="subWrap" class="note hidden"></div>
|
|
1087
1089
|
<div id="keyWrap"><label>API key</label><input id="key" type="password" placeholder="paste your key">
|
|
1088
1090
|
<div class="note"><a id="getkey">Where do I get a key?</a></div></div>
|
|
1089
1091
|
<div id="baseWrap" class="hidden"><label>Ollama URL</label><input id="baseUrl" type="text" value="http://localhost:11434"></div>
|
|
@@ -1174,8 +1176,8 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
|
|
|
1174
1176
|
<script>
|
|
1175
1177
|
var T=new URLSearchParams(window.location.search).get('t')||'';
|
|
1176
1178
|
function api(path,body){return fetch(path,{method:body?'POST':'GET',headers:{'x-symbiot-token':T,'content-type':'application/json'},body:body?JSON.stringify(body):undefined}).then(function(r){return r.json();});}
|
|
1177
|
-
var KEYURL={anthropic:'https://console.anthropic.com/settings/keys',openai:'https://platform.openai.com/api-keys',gemini:'https://aistudio.google.com/apikey',ollama:'https://ollama.com'};
|
|
1178
|
-
var DEFMODEL={anthropic:'claude-opus-5-5',openai:'gpt-4o-mini',gemini:'gemini-1.5-flash',ollama:'llama3.1'};
|
|
1179
|
+
var KEYURL={claude:'https://claude.com/claude-code',anthropic:'https://console.anthropic.com/settings/keys',openai:'https://platform.openai.com/api-keys',gemini:'https://aistudio.google.com/apikey',ollama:'https://ollama.com'};
|
|
1180
|
+
var DEFMODEL={claude:'your plan’s default',anthropic:'claude-opus-5-5',openai:'gpt-4o-mini',gemini:'gemini-1.5-flash',ollama:'llama3.1'};
|
|
1179
1181
|
function $(id){return document.getElementById(id);}
|
|
1180
1182
|
// Live updates never pull text out from under your selection. A refresh that would
|
|
1181
1183
|
// redraw what you're selecting in waits until you let it go (selWait), and a redraw
|
|
@@ -1233,7 +1235,8 @@ function firstTab(){api('/api/watch/board').then(function(b){if(b&&b.cards&&b.ca
|
|
|
1233
1235
|
// The AI in use isn't shown while it works (it's noise); only when none is
|
|
1234
1236
|
// connected, as an urgent line that opens its part of Settings. Home has it out
|
|
1235
1237
|
// front too, with the agent and the connectors runs use (home.mjs).
|
|
1236
|
-
|
|
1238
|
+
var SUBLINE='';
|
|
1239
|
+
function refresh(){api('/api/status').then(function(s){if(s&&s.connected&&s.provider){var pv=$('provider');if(pv&&!pv.getAttribute('data-touched')){pv.value=s.provider;SUBLINE=s.provider==='claude'?'Connected: '+s.line+'. No key needed.':'';syncP();}}var st=$('status');if(!st)return;st.classList.toggle('hidden',!!s.connected);st.classList.toggle('urgent',!s.connected);st.textContent=s.connected?'':"No AI connected: Symbiot can't work. Connect one \\u203a";});}
|
|
1237
1240
|
// the status opens Settings: on a phone its tab is scrolled out of sight
|
|
1238
1241
|
$('status').addEventListener('click',function(){var b=document.body;if(b&&b.classList&&b.classList.contains('lq-liquid')&&typeof lqFocus==='function'){lqFocus({kind:'setup',focus:'ai'});return;}setTab('settings');var t=document.querySelector('.tab[data-tab=settings]');if(t&&t.scrollIntoView)t.scrollIntoView({block:'nearest',inline:'nearest'});});
|
|
1239
1242
|
// Where you are: the section you opened, named at the top of its panel (and the
|
|
@@ -1261,9 +1264,9 @@ api('/api/run',{cmd:current}).then(function(r){var f=$('outfoot');if(r.error==='
|
|
|
1261
1264
|
var foot=[r.footer,r.file?'Saved to '+r.file:'',r.error&&r.text&&r.error!==r.text?r.error:''].filter(Boolean).join(' · ');
|
|
1262
1265
|
WUTEXT=r.text||'';$('out').innerHTML=r.text?mdLite(r.text):'(no output)';$('out').classList.remove('muted');$('copy').classList.remove('hidden');if(foot){f.textContent=foot;f.style.display='block';}else{f.style.display='none';}});});
|
|
1263
1266
|
$('copy').addEventListener('click',function(){var c=$('copy');try{navigator.clipboard.writeText(WUTEXT||$('out').textContent);}catch(e){}c.setAttribute('title','Copied');c.classList.add('done');setTimeout(function(){c.setAttribute('title','Copy');c.classList.remove('done');},1400);});
|
|
1264
|
-
function syncP(){var p=$('provider').value;var local=p==='ollama';$('keyWrap').classList.toggle('hidden',local);$('baseWrap').classList.toggle('hidden',!local);
|
|
1267
|
+
function syncP(){var p=$('provider').value;var local=p==='ollama',sub=p==='claude';$('keyWrap').classList.toggle('hidden',local||sub);$('baseWrap').classList.toggle('hidden',!local);var sw=$('subWrap');if(sw){sw.classList.toggle('hidden',!sub);sw.textContent=sub?(SUBLINE||'Uses Claude Code signed in on this computer with your Claude account. Not signed in yet? Run claude once in a terminal and sign in, then Save.'):'';}
|
|
1265
1268
|
$('getkey').textContent=local?'About Ollama':'Where do I get a key?';$('modelHint').textContent='(default '+DEFMODEL[p]+')';$('model').placeholder='(blank = '+DEFMODEL[p]+')';}
|
|
1266
|
-
$('provider').addEventListener('change',syncP);
|
|
1269
|
+
$('provider').addEventListener('change',function(){$('provider').setAttribute('data-touched','1');syncP();});
|
|
1267
1270
|
$('getkey').addEventListener('click',function(){window.open(KEYURL[$('provider').value],'_blank');});
|
|
1268
1271
|
$('save').addEventListener('click',function(){var p=$('provider').value;$('saveMsg').textContent='checking...';$('saveMsg').className='note muted';
|
|
1269
1272
|
var cfg={provider:p,model:$('model').value.trim()};if(p==='ollama')cfg.baseUrl=$('baseUrl').value.trim();else cfg.key=$('key').value.trim();
|