Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 25 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,26 @@ map laid out from a description, an area redone, triggers written and explained,
and a briefing, a review, string rewrites, and an assistant beside the map that reads and
edits everything in it with you.

There is nothing to set up. The first AI request starts a free trial with no sign-in;
when it is spent you sign in (Discord to start with) for the sign-in credit, and buy
credit at cost when that runs out. The plugin holds no model key and has no field for a
server, a token or a key: it talks to scmjs.dev and to nothing else, and it sends nothing
until you press a button — with no session stored it makes no request at startup. Every
menu item that reaches the network carries the plugin's mark.

The server decides who gets the AI. When it keeps the AI to some accounts (scmjs.dev
does for now), everyone else sees no Tools ▸ AI menu, assistant, AI buttons or AI page
in Preferences; the account, map storage and shared maps are the same for everybody.
The plugin remembers the server's last answer, so until it has asked once — a sign-in,
or opening the Account dialog — the AI stays hidden.
The AI runs on your own Anthropic API key. Paste one (made at
[console.anthropic.com](https://console.anthropic.com)) under **Your Anthropic key** on
the plugin's page in Edit ▸ Preferences — the Account dialog's *Add your key…* goes
there. The server checks it with Anthropic first, which costs nothing, and only a key
that passes is kept; from then on it goes with each AI request, Anthropic bills the
requests to you, and the Tools ▸ AI menu, the assistant and the AI buttons are there.
The key stays in this browser: scmjs.dev passes it on and keeps no copy. **Forget the
key** on the same page takes it, and the AI features, away again. Replay casts in
Aftermath are not offered on your own key.

An account whose role the server pays for (an admin on scmjs.dev) gets the AI without a
key, on its balance as before — the free trial and the sign-in credit are that path, on
a server that offers them. Everyone else, without a key, sees no Tools ▸ AI menu,
assistant or AI buttons; the account, map storage and shared maps are the same for
everybody. The plugin remembers the server's last answer, so until it has asked once — a
sign-in, or opening the Account dialog — the key field and the AI stay hidden.

The plugin has no field for a server or a token: it talks to scmjs.dev and to nothing
else, and it sends nothing until you press a button — with no session stored it makes no
request at startup. Every menu item that reaches the network carries the plugin's mark.

## Install

Expand Down Expand Up @@ -313,7 +321,8 @@ model answers is the service's business and is never asked.
Every AI dialog shows how long it has been waiting, and once the answer is back what it
cost, what the session has cost so far, and what is left on the account. Roughly: a map
plan is a few tens of cents, a trigger script and a review about the same, a name or a
translation a few cents. The trial and the sign-in credit are what the Account dialog
translation a few cents. On your own key those are Anthropic's charges to you, and the
balance line says so instead. The trial and the sign-in credit are what the Account dialog
says; credit bought on top is charged at cost, does not expire, and is spent after any
weekly allowance. When the balance is empty the dialog says so and links to the Account
dialog — sign in if you were on the trial, top up or wait for the refill if you were not.
Expand Down Expand Up @@ -342,9 +351,9 @@ api.services.watch<ScmjsAccountService>("scmjs-dev.account", (account) => {
## What is stored, and where

In this browser (Preferences ▸ Browser storage, under the plugin): the session, a random
device id the one free trial is keyed by, the ticks and the AI options, and the name you
last joined a shared map under. On the server: your provider id and display name, a ledger
of what your calls cost, the maps you stored and their links (a map behind a link can be
device id the one free trial is keyed by, the ticks and the AI options, your Anthropic
key if you gave one, and the name you last joined a shared map under. On the server: your provider id and display name, a ledger
of what your calls cost (none for calls on your own key), the maps you stored and their links (a map behind a link can be
downloaded by anyone who has the link) — and, only while a map is shared, a copy
of it and the changes since in the server's memory. Nothing else — never a prompt, never a card. The account page on scmjs.dev
deletes all of it.
Expand Down
54 changes: 48 additions & 6 deletions account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
*
* Settings are the plugin's own `api.storage` (listed in Preferences ▸ Browser storage
* under the plugin): the session, the device id the one trial is keyed by, the ticks in
* the Account dialog, and the AI options. The server is `https://api.scmjs.dev` and
* the Account dialog, the AI options, and the person's own Anthropic key when they gave
* one — which is what the AI runs on for everyone but an admin. The server is `https://api.scmjs.dev` and
* there is no field for it: `serverOverride` is the one way to point a development
* build elsewhere, a `?scmjs-server=` query on the editor's address.
*/
Expand Down Expand Up @@ -59,12 +60,19 @@ export interface Settings {
* the server nothing; off until the server has said so once.
*/
aiOffered: boolean;
/**
* The person's own Anthropic key, stored only once the server has checked it with
* Anthropic. Sent with AI requests alone; scmjs.dev forwards it and keeps no copy.
*/
anthropicKey: string;
/** The server takes an own key (`/v1/info`'s `access.byok`), from its last answer. */
keysAccepted: boolean;
}

export const DEFAULT_SETTINGS: Settings = {
serverUrl: DEFAULT_SERVER_URL, session: "", deviceId: "", statusItem: true,
ai: true, quality: "standard", showThinking: true, maxRounds: 24, ceilingUsd: 0.5, scenarioCeilingUsd: 1.5, attachView: false, dockAssistant: false, followMap: true,
shareName: "", aiOffered: false,
shareName: "", aiOffered: false, anthropicKey: "", keysAccepted: false,
};

const KEY = "settings";
Expand Down Expand Up @@ -155,8 +163,8 @@ export class AccountManager {
this.store = store;
this.client = client;
this.deps = deps;
// An AI request with no session starts the trial first, and the balance follows every result.
client.prepare = () => this.ensureSession();
// An AI request with no session starts the trial first — unless it runs on the person's own key — and the balance follows every result.
client.prepare = () => this.ownKey() ? Promise.resolve() : this.ensureSession();
client.onRemaining = (r) => this.noteRemaining(r);
}

Expand Down Expand Up @@ -192,11 +200,38 @@ export class AccountManager {
casts: { on: boolean; voiced: boolean } | null = null;

aiOffered(): boolean {
if (this.ownKey()) return true;
if (this.view) return this.view.ai ?? true;
if (this.info && !this.store.get().session) return this.info.ai ?? true;
return this.store.get().aiOffered;
}

/** The AI runs on the person's own Anthropic key: one is stored and the server takes it. */
ownKey(): boolean {
const s = this.store.get();
return !!s.anthropicKey && s.keysAccepted;
}

/** The server takes an own key, as it last said. */
keysAccepted(): boolean { return this.store.get().keysAccepted; }

/**
* Check `key` with the server (which asks Anthropic, at no cost) and keep it when it
* passes; a key that fails is not stored, and the error says why.
*/
async useKey(key: string): Promise<void> {
const trimmed = key.trim();
if (!trimmed) throw new ScmjsError("invalid_input", t("Paste the key first."));
await this.client.checkKey(trimmed);
this.store.set({ anthropicKey: trimmed, keysAccepted: true });
this.changed();
}

forgetKey(): void {
this.store.set({ anthropicKey: "" });
this.changed();
}

private changed() {
const offered = this.aiOffered();
if (offered !== this.store.get().aiOffered) this.store.set({ aiOffered: offered });
Expand All @@ -208,9 +243,14 @@ export class AccountManager {
summary(): string {
const v = this.view;
switch (this.kind()) {
case "guest": return this.aiOffered() && this.info?.trial !== false ? t("Not signed in · the first AI request starts a free trial") : t("Not signed in");
case "trial": return v ? t("Free trial · {balance} left · sign in to keep it and get more", { balance: formatUsd(v.balanceUsd) }) : t("Free trial");
case "guest":
if (this.ownKey()) return t("Not signed in · AI requests use your own Anthropic key");
return this.aiOffered() && this.info?.trial !== false ? t("Not signed in · the first AI request starts a free trial") : t("Not signed in");
case "trial":
if (this.ownKey()) return t("Not signed in · AI requests use your own Anthropic key");
return v ? t("Free trial · {balance} left · sign in to keep it and get more", { balance: formatUsd(v.balanceUsd) }) : t("Free trial");
default: {
if (this.ownKey()) return t("{name} · AI requests use your own Anthropic key", { name: v?.name ?? t("Signed in") });
if (!v) return t("Signed in");
const name = v.name ?? t("Signed in");
if (v.unlimited) return t("{name} · no balance is kept", { name });
Expand All @@ -235,6 +275,8 @@ export class AccountManager {
const cast = info.recipes?.find((r) => (r.name as string) === "cast");
this.casts = cast ? { on: cast.enabled, voiced: !!(info as { speech?: boolean }).speech } : null;
this.serverName = info.name;
const accepted = !!info.access?.byok;
if (accepted !== this.store.get().keysAccepted) this.store.set({ keysAccepted: accepted });
if (info.caller.kind === "user" && info.caller.account) this.view = info.caller.account;
else if (this.store.get().session && info.caller.kind === "anonymous") {
// The session was sent and not recognised: it has ended.
Expand Down
Loading
Loading