@tiagojct/subsub 0.11.3 → 0.12.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 +64 -0
- package/README.md +10 -5
- package/dist/bench.js +10 -4
- package/dist/cli.js +6 -0
- package/dist/config.js +28 -1
- package/dist/init.js +49 -3
- package/dist/keys.js +81 -0
- package/dist/preview.js +101 -26
- package/dist/subsub.js +74 -14
- package/dist/usage.js +69 -0
- package/dist/web.js +5 -37
- package/package.json +4 -3
- package/src/bench.ts +10 -4
- package/src/cli.ts +6 -0
- package/src/config.ts +30 -1
- package/src/init.ts +60 -2
- package/src/keys.ts +79 -0
- package/src/preview.ts +100 -25
- package/src/subsub.ts +72 -13
- package/src/usage.ts +75 -0
- package/src/web.ts +5 -35
- package/web/app.css +24 -0
- package/web/app.js +135 -22
- package/web/i18n.js +58 -0
- package/web/index.html +3 -0
package/src/keys.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Model providers that take an API key, and plain words for their common errors. */
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `where`: where the provider processes the data, as a key into the web view's strings
|
|
8
|
+
* (eu, us, cn, varies). Shown next to the key form, so the choice of provider is an informed one.
|
|
9
|
+
*/
|
|
10
|
+
export const KEY_PROVIDERS: Array<{ id: string; label: string; url: string; where: "eu" | "us" | "cn" | "varies" }> = [
|
|
11
|
+
{ id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth", where: "varies" },
|
|
12
|
+
{ id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys", where: "varies" },
|
|
13
|
+
{ id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys", where: "us" },
|
|
14
|
+
{ id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys", where: "us" },
|
|
15
|
+
{ id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey", where: "us" },
|
|
16
|
+
{ id: "mistral", label: "Mistral (EU)", url: "https://console.mistral.ai/api-keys", where: "eu" },
|
|
17
|
+
{ id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys", where: "cn" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
|
|
21
|
+
export function saveApiKey(agentDir: string, provider: string, key: string): void {
|
|
22
|
+
if (!KEY_PROVIDERS.some((p) => p.id === provider)) throw new Error(`Unknown provider: ${provider}`);
|
|
23
|
+
const clean = key.trim();
|
|
24
|
+
if (!clean || /\s/.test(clean) || clean.length > 400) throw new Error("That does not look like an API key.");
|
|
25
|
+
mkdirSync(agentDir, { recursive: true });
|
|
26
|
+
const file = join(agentDir, "auth.json");
|
|
27
|
+
let data: Record<string, unknown> = {};
|
|
28
|
+
if (existsSync(file)) {
|
|
29
|
+
try {
|
|
30
|
+
data = JSON.parse(readFileSync(file, "utf8")) ?? {};
|
|
31
|
+
} catch {
|
|
32
|
+
throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
data[provider] = { type: "api_key", key: clean };
|
|
36
|
+
// Write through a symlink (a shared pi auth.json) rather than replacing it.
|
|
37
|
+
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
export type Trouble = "quota" | "busy" | "blocked" | "key";
|
|
42
|
+
|
|
43
|
+
/** What a provider's error means for the user, or undefined when it is something else. */
|
|
44
|
+
export function providerTrouble(text: string | undefined): Trouble | undefined {
|
|
45
|
+
if (!text) return undefined;
|
|
46
|
+
if (/RESOURCE_EXHAUSTED|\b429\b|quota|rate.?limit|too many requests|usage limit/i.test(text)) return "quota";
|
|
47
|
+
if (/\b(503|529)\b|UNAVAILABLE|high demand|overloaded/i.test(text)) return "busy";
|
|
48
|
+
if (/RECITATION|SAFETY|PROHIBITED_CONTENT/.test(text)) return "blocked";
|
|
49
|
+
if (/\b(401|403)\b|API key not valid|invalid api key|PERMISSION_DENIED|UNAUTHENTICATED/i.test(text)) return "key";
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The words for each kind in notices (the web view has its own, in web/i18n.js). */
|
|
54
|
+
export const TROUBLE_TEXT_PT: Record<Trouble, string> = {
|
|
55
|
+
quota: "O fornecedor do modelo recusou o pedido: atingiu um limite da sua conta. Os níveis gratuitos limitam o texto enviado por minuto e os pedidos por dia. Espere um minuto e peça ao Sub-Sub para continuar; se voltar a acontecer, esgotou o limite diário (renova-se no dia seguinte). Ou use outro modelo (/model).",
|
|
56
|
+
busy: "O fornecedor do modelo está sobrecarregado neste momento. Tente outra vez dentro de alguns minutos ou use outro modelo (/model).",
|
|
57
|
+
blocked: "O fornecedor do modelo interrompeu a resposta (por exemplo, por repetir uma fonte palavra por palavra). Peça outra vez por outras palavras ou use outro modelo (/model).",
|
|
58
|
+
key: "O fornecedor do modelo não aceitou a chave da API. Verifique-a ou introduza uma nova com /login.",
|
|
59
|
+
};
|
|
60
|
+
export const TROUBLE_TEXT: Record<Trouble, string> = {
|
|
61
|
+
quota: "The model provider refused the request: a limit of your account was reached. Free tiers limit the text sent each minute and the requests each day. Wait a minute and ask Sub-Sub to continue; if it happens again, the daily limit is used up (it resets the next day). Or use another model (/model).",
|
|
62
|
+
busy: "The model provider is busy at the moment. Try again in a few minutes, or use another model (/model).",
|
|
63
|
+
blocked: "The model provider stopped the reply (for example, because it repeated a source word for word). Ask again in other words, or use another model (/model).",
|
|
64
|
+
key: "The model provider did not accept the API key. Check it, or enter a new one with /login.",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Seconds to wait before a per-minute limit lets the request through again, or undefined when the
|
|
69
|
+
* error is not a per-minute limit (a daily limit, a spent balance, anything else). pi does not
|
|
70
|
+
* retry Google's per-minute 429, because its text mentions billing.
|
|
71
|
+
*/
|
|
72
|
+
export function perMinuteWait(text: string | undefined): number | undefined {
|
|
73
|
+
if (!text || providerTrouble(text) !== "quota") return undefined;
|
|
74
|
+
if (/PerDay|per day|daily/i.test(text)) return undefined;
|
|
75
|
+
const delay = /retryDelay\\*"\s*:\s*\\*"(\d+(?:\.\d+)?)s/.exec(text);
|
|
76
|
+
const seconds = delay?.[1] ?? /retry in (\d+(?:\.\d+)?)s/i.exec(text)?.[1];
|
|
77
|
+
if (seconds) return Math.min(120, Math.ceil(Number(seconds)) + 2);
|
|
78
|
+
return /PerMinute|per minute/i.test(text) ? 60 : undefined;
|
|
79
|
+
}
|
package/src/preview.ts
CHANGED
|
@@ -5,6 +5,62 @@
|
|
|
5
5
|
|
|
6
6
|
const MAX_LINES = 14;
|
|
7
7
|
|
|
8
|
+
export type Lang = "en" | "pt";
|
|
9
|
+
|
|
10
|
+
/** The words of the preview in English and European Portuguese; tags, titles and values stay as they are. */
|
|
11
|
+
const WORDS = {
|
|
12
|
+
en: {
|
|
13
|
+
more: (n: number) => `... and ${n} more`,
|
|
14
|
+
moreChars: (n: number) => `[${n} more characters not shown]`,
|
|
15
|
+
empty: "(empty)",
|
|
16
|
+
change: "(change)",
|
|
17
|
+
undo: "Undo",
|
|
18
|
+
already: "ALREADY APPLIED",
|
|
19
|
+
drift: (n: number) => `${n} item(s) changed after the note was written; the note overwrites them:`,
|
|
20
|
+
now: "now",
|
|
21
|
+
none: "(none)",
|
|
22
|
+
wouldChange: (n: number) => `${n} item(s) would change.`,
|
|
23
|
+
inShort: (listed: number, total: number) => (listed < total ? `In short (in the ${listed} items listed):` : "In short:"),
|
|
24
|
+
items: "Items:",
|
|
25
|
+
itemsN: (n: number) => `${n} item(s)`,
|
|
26
|
+
changed: "changed",
|
|
27
|
+
wouldImport: (n: number) => `${n} item(s) would be imported:`,
|
|
28
|
+
noAbstract: "(no abstract)",
|
|
29
|
+
wouldAttach: (n: number) => `${n} PDF(s) would be attached:`,
|
|
30
|
+
newCollection: "New collection",
|
|
31
|
+
inside: "inside",
|
|
32
|
+
noteOn: "Note on",
|
|
33
|
+
link: "Link",
|
|
34
|
+
skipped: "Skipped",
|
|
35
|
+
errors: "Errors",
|
|
36
|
+
},
|
|
37
|
+
pt: {
|
|
38
|
+
more: (n: number) => `... e mais ${n}`,
|
|
39
|
+
moreChars: (n: number) => `[mais ${n} caracteres não mostrados]`,
|
|
40
|
+
empty: "(vazio)",
|
|
41
|
+
change: "(alteração)",
|
|
42
|
+
undo: "Anular",
|
|
43
|
+
already: "JÁ APLICADO",
|
|
44
|
+
drift: (n: number) => `${n} item(ns) mudaram depois de a nota ser escrita; a nota substitui-os:`,
|
|
45
|
+
now: "agora",
|
|
46
|
+
none: "(nenhuma)",
|
|
47
|
+
wouldChange: (n: number) => `${n} item(ns) vão mudar.`,
|
|
48
|
+
inShort: (listed: number, total: number) => (listed < total ? `Em resumo (nos ${listed} itens listados):` : "Em resumo:"),
|
|
49
|
+
items: "Itens:",
|
|
50
|
+
itemsN: (n: number) => `${n} item(ns)`,
|
|
51
|
+
changed: "alterado",
|
|
52
|
+
wouldImport: (n: number) => `${n} item(ns) vão ser importados:`,
|
|
53
|
+
noAbstract: "(sem resumo)",
|
|
54
|
+
wouldAttach: (n: number) => `${n} PDF(s) vão ser anexados:`,
|
|
55
|
+
newCollection: "Nova coleção",
|
|
56
|
+
inside: "dentro de",
|
|
57
|
+
noteOn: "Nota sobre",
|
|
58
|
+
link: "Ligação",
|
|
59
|
+
skipped: "Ignorados",
|
|
60
|
+
errors: "Erros",
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
8
64
|
type Obj = Record<string, unknown>;
|
|
9
65
|
|
|
10
66
|
function s(v: unknown, n = 90): string {
|
|
@@ -13,19 +69,19 @@ function s(v: unknown, n = 90): string {
|
|
|
13
69
|
}
|
|
14
70
|
|
|
15
71
|
/** What will be written: shown whole up to a large limit, and the rest is counted, never silently cut. */
|
|
16
|
-
function whole(v: unknown, n = 3000): string {
|
|
72
|
+
function whole(v: unknown, w: (typeof WORDS)[Lang], n = 3000): string {
|
|
17
73
|
const t = typeof v === "string" ? v : JSON.stringify(v);
|
|
18
|
-
return t.length > n ? `${t.slice(0, n)}
|
|
74
|
+
return t.length > n ? `${t.slice(0, n)} ${w.moreChars(t.length - n)}` : t;
|
|
19
75
|
}
|
|
20
76
|
|
|
21
|
-
function list(rows: string[], total?: number): string[] {
|
|
77
|
+
function list(rows: string[], w: (typeof WORDS)[Lang], total?: number): string[] {
|
|
22
78
|
const out = rows.slice(0, MAX_LINES);
|
|
23
79
|
const rest = (total ?? rows.length) - out.length;
|
|
24
|
-
if (rest > 0) out.push(
|
|
80
|
+
if (rest > 0) out.push(w.more(rest));
|
|
25
81
|
return out;
|
|
26
82
|
}
|
|
27
83
|
|
|
28
|
-
function describeChange(c: Obj): string {
|
|
84
|
+
function describeChange(c: Obj, w: (typeof WORDS)[Lang]): string {
|
|
29
85
|
const parts: string[] = [];
|
|
30
86
|
if (Array.isArray(c.added) && c.added.length) parts.push(`+ ${c.added.join(", ")}`);
|
|
31
87
|
if (Array.isArray(c.removed) && c.removed.length) parts.push(`- ${c.removed.join(", ")}`);
|
|
@@ -33,73 +89,92 @@ function describeChange(c: Obj): string {
|
|
|
33
89
|
if (["key", "item", "added", "removed", "tags_after"].includes(k)) continue;
|
|
34
90
|
if (v && typeof v === "object" && "after" in (v as Obj)) {
|
|
35
91
|
const b = (v as Obj).before;
|
|
36
|
-
parts.push(`${k}: ${s(b ||
|
|
92
|
+
parts.push(`${k}: ${s(b || w.empty, 40)} -> ${whole((v as Obj).after, w)}`);
|
|
37
93
|
}
|
|
38
94
|
}
|
|
39
|
-
return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") ||
|
|
95
|
+
return `${s(c.item ?? c.key, 60)}: ${parts.join("; ") || w.change}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** For a large batch: each tag and field with the number of items it changes, so the whole is readable at once. */
|
|
99
|
+
function tally(changes: Obj[], total: number, w: (typeof WORDS)[Lang]): string[] {
|
|
100
|
+
const count = new Map<string, number>();
|
|
101
|
+
const add = (k: string) => count.set(k, (count.get(k) ?? 0) + 1);
|
|
102
|
+
for (const c of changes) {
|
|
103
|
+
for (const tag of (c.added as string[] | undefined) ?? []) add(`+ ${tag}`);
|
|
104
|
+
for (const tag of (c.removed as string[] | undefined) ?? []) add(`- ${tag}`);
|
|
105
|
+
for (const [k, v] of Object.entries(c)) if (v && typeof v === "object" && "after" in (v as Obj)) add(`${k} ${w.changed}`);
|
|
106
|
+
}
|
|
107
|
+
if (!count.size) return [];
|
|
108
|
+
const rank = (k: string) => (k.startsWith("+") ? 0 : k.startsWith("-") ? 1 : 2);
|
|
109
|
+
const rows = [...count].sort((a, b) => rank(a[0]) - rank(b[0]) || b[1] - a[1] || a[0].localeCompare(b[0])).map(([k, n]) => ` ${k}: ${w.itemsN(n)}`);
|
|
110
|
+
return [w.inShort(changes.length, total), ...list(rows, w), w.items];
|
|
40
111
|
}
|
|
41
112
|
|
|
42
|
-
export function formatPreview(tool: string, preview: unknown): string {
|
|
113
|
+
export function formatPreview(tool: string, preview: unknown, lang: Lang = "en"): string {
|
|
114
|
+
const w = WORDS[lang];
|
|
43
115
|
if (!preview || typeof preview !== "object") return s(preview, 1500);
|
|
44
116
|
const p = preview as Obj;
|
|
45
117
|
const lines: string[] = [];
|
|
46
118
|
if (p.undoing && typeof p.undoing === "object") {
|
|
47
119
|
const u = p.undoing as Obj;
|
|
48
|
-
lines.push(
|
|
120
|
+
lines.push(`${w.undo} ${u.op}: ${s(u.summary, 80)}`);
|
|
49
121
|
}
|
|
50
122
|
if (Array.isArray(p.already_applied) && p.already_applied.length) {
|
|
51
|
-
lines.push(
|
|
123
|
+
lines.push(`${w.already}: ${p.already_applied.join(", ")}`);
|
|
52
124
|
}
|
|
53
125
|
const drift = p.changed_since_note as Obj | undefined;
|
|
54
126
|
if (drift && typeof drift === "object" && typeof drift.count === "number") {
|
|
55
|
-
lines.push(
|
|
127
|
+
lines.push(w.drift(drift.count));
|
|
56
128
|
const items = Object.entries((drift.items as Obj) ?? {}).map(
|
|
57
|
-
([k, v]) => ` ${k} now: ${((v as Obj).now as string[] | undefined)?.join(", ") ||
|
|
129
|
+
([k, v]) => ` ${k} ${w.now}: ${((v as Obj).now as string[] | undefined)?.join(", ") || w.none}`,
|
|
58
130
|
);
|
|
59
131
|
lines.push(...items.slice(0, 4));
|
|
60
132
|
}
|
|
61
133
|
if (typeof p.would_change === "number") {
|
|
62
|
-
lines.push(
|
|
134
|
+
lines.push(w.wouldChange(p.would_change));
|
|
63
135
|
const changes = (p.changes as Obj[]) ?? [];
|
|
64
|
-
lines.push(...
|
|
136
|
+
if (p.would_change > 10) lines.push(...tally(changes, p.would_change as number, w));
|
|
137
|
+
lines.push(...list(changes.map((c) => describeChange(c, w)), w, p.would_change as number));
|
|
65
138
|
}
|
|
66
139
|
if (Array.isArray(p.would_import)) {
|
|
67
|
-
lines.push(
|
|
140
|
+
lines.push(w.wouldImport(p.would_import.length));
|
|
68
141
|
lines.push(
|
|
69
142
|
...list(
|
|
70
143
|
(p.would_import as Obj[]).map(
|
|
71
|
-
(r) => `${r.citekey} ${s(r.item, 70)}${r.has_abstract ? "" :
|
|
144
|
+
(r) => `${r.citekey} ${s(r.item, 70)}${r.has_abstract ? "" : ` ${w.noAbstract}`}`,
|
|
72
145
|
),
|
|
146
|
+
w,
|
|
73
147
|
),
|
|
74
148
|
);
|
|
75
149
|
}
|
|
76
150
|
if (Array.isArray(p.would_attach)) {
|
|
77
|
-
lines.push(
|
|
151
|
+
lines.push(w.wouldAttach(p.would_attach.length));
|
|
78
152
|
lines.push(
|
|
79
153
|
...list(
|
|
80
154
|
(p.would_attach as Obj[]).map(
|
|
81
155
|
(r) => `${s(r.item, 60)} [${r.host_type ?? "?"}, ${r.version ?? "?"}${r.license ? `, ${r.license}` : ""}]`,
|
|
82
156
|
),
|
|
157
|
+
w,
|
|
83
158
|
),
|
|
84
159
|
);
|
|
85
160
|
}
|
|
86
161
|
if (p.would_create && typeof p.would_create === "object") {
|
|
87
|
-
const
|
|
88
|
-
lines.push(
|
|
162
|
+
const nc = p.would_create as Obj;
|
|
163
|
+
lines.push(`${w.newCollection}: ${nc.name}${nc.parent ? ` (${w.inside} ${nc.parent})` : ""}`);
|
|
89
164
|
}
|
|
90
165
|
if (tool.endsWith("attach_note") || tool.endsWith("create_note")) {
|
|
91
|
-
if (p.item || p.parent) lines.push(
|
|
92
|
-
if (p.summary || p.note_preview) lines.push(whole(p.summary ?? p.note_preview));
|
|
93
|
-
if (p.link) lines.push(
|
|
166
|
+
if (p.item || p.parent) lines.push(`${w.noteOn}: ${s(p.item ?? p.parent, 90)}`);
|
|
167
|
+
if (p.summary || p.note_preview) lines.push(whole(p.summary ?? p.note_preview, w));
|
|
168
|
+
if (p.link) lines.push(`${w.link}: ${p.link}`);
|
|
94
169
|
}
|
|
95
170
|
const skipped = p.skipped as Obj | undefined;
|
|
96
171
|
if (skipped && typeof skipped === "object" && Object.keys(skipped).length) {
|
|
97
|
-
lines.push(
|
|
98
|
-
lines.push(...list(Object.entries(skipped).map(([k, v]) => ` ${k}: ${s(v, 70)}`)).slice(0, 5));
|
|
172
|
+
lines.push(`${w.skipped}: ${Object.keys(skipped).length}`);
|
|
173
|
+
lines.push(...list(Object.entries(skipped).map(([k, v]) => ` ${k}: ${s(v, 70)}`), w).slice(0, 5));
|
|
99
174
|
}
|
|
100
175
|
const errors = p.errors as Obj | undefined;
|
|
101
176
|
if (errors && typeof errors === "object" && Object.keys(errors).length) {
|
|
102
|
-
lines.push(
|
|
177
|
+
lines.push(`${w.errors}: ${Object.keys(errors).length}`);
|
|
103
178
|
lines.push(...Object.entries(errors).slice(0, 5).map(([k, v]) => ` ${k}: ${s(v, 70)}`));
|
|
104
179
|
}
|
|
105
180
|
if (!lines.length) lines.push(s(p, 1500));
|
package/src/subsub.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
starbuckCommand,
|
|
35
35
|
starbuckEnabled,
|
|
36
36
|
type SubsubConfig,
|
|
37
|
+
uiLanguage,
|
|
37
38
|
who,
|
|
38
39
|
} from "./config.ts";
|
|
39
40
|
import { profileList, profileSpec, promptBlocked } from "./profiles.ts";
|
|
@@ -41,8 +42,10 @@ import { coerceArgs, loosenArrays, resolvePathArgs } from "./args.ts";
|
|
|
41
42
|
import { applyEdits, checkLiteratureNote } from "./notes.ts";
|
|
42
43
|
import { buildPolicy, isSecret, judgePath, normalizeToolPath, realish, within } from "./paths.ts";
|
|
43
44
|
import { describeArgs, formatPreview } from "./preview.ts";
|
|
45
|
+
import { perMinuteWait, providerTrouble, TROUBLE_TEXT, TROUBLE_TEXT_PT } from "./keys.ts";
|
|
44
46
|
import { headerLines, type LibraryState, paintPreview, QUOTES, type Scheme, schemeFromAnsi, themeName } from "./look.ts";
|
|
45
47
|
import { systemAddition } from "./prompt.ts";
|
|
48
|
+
import { logUsage } from "./usage.ts";
|
|
46
49
|
import { gateKind, SERVER_PREFIXES, toolsFor, unavailableReason } from "./roles.ts";
|
|
47
50
|
|
|
48
51
|
const MODE_ENTRY = "subsub-mode";
|
|
@@ -95,6 +98,10 @@ export function starbuckEnv(cfg: Pick<SubsubConfig, "envFile">, processEnv: Node
|
|
|
95
98
|
|
|
96
99
|
export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Promise<void> {
|
|
97
100
|
const cfg = deps.config ?? loadConfig();
|
|
101
|
+
// Notices, dialogs and previews in the language of the interface (English or European Portuguese).
|
|
102
|
+
const lang = uiLanguage(cfg.language);
|
|
103
|
+
const L = (en: string, pt: string) => (lang === "pt" ? pt : en);
|
|
104
|
+
const trouble = lang === "pt" ? TROUBLE_TEXT_PT : TROUBLE_TEXT;
|
|
98
105
|
let bridge: Bridge | undefined = deps.bridge;
|
|
99
106
|
const toolNames = new Set<string>();
|
|
100
107
|
/** Original input schemas, to turn "[...]" strings back into lists. */
|
|
@@ -150,6 +157,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
150
157
|
const lookOn = (ctx: ExtensionContext) => standalone && cfg.look !== false && ctx.mode === "tui";
|
|
151
158
|
/** True under `subsub web`: pi runs in RPC mode and the web view shows the state that the terminal header shows. */
|
|
152
159
|
const webView = process.env.SUBSUB_WEB === "1";
|
|
160
|
+
let lastError: string | undefined;
|
|
161
|
+
let resumes = 0;
|
|
162
|
+
let resumeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
163
|
+
let turnStart = 0;
|
|
164
|
+
let turnTools = 0;
|
|
153
165
|
let webCtx: ExtensionContext | undefined;
|
|
154
166
|
function emitWeb(): void {
|
|
155
167
|
const ctx = webCtx;
|
|
@@ -291,19 +303,21 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
291
303
|
if (lookOn(ctx)) scheme ??= schemeFromAnsi(ctx.ui.theme.getFgAnsi("text"), ctx.ui.theme.name);
|
|
292
304
|
applyTheme(ctx);
|
|
293
305
|
await applyMode(ctx, restored ?? (pi.getFlag("librarian") ? "librarian" : pi.getFlag("researcher") ? "researcher" : cfg.defaultMode), false, !modelChosen);
|
|
306
|
+
logUsage(cfg.usageLog, { ev: "session", mode, profile, model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined });
|
|
294
307
|
if (lookOn(ctx)) {
|
|
295
308
|
setHeader(ctx);
|
|
296
309
|
void refreshLibrary();
|
|
297
310
|
} else if (webView) {
|
|
298
311
|
void refreshLibrary();
|
|
299
312
|
}
|
|
300
|
-
if (!ctx.model && !webView) ctx.ui.notify("Sub-Sub: no model is connected yet. Type /login and select a provider (or use the model button in subsub web).", "warning");
|
|
313
|
+
if (!ctx.model && !webView) ctx.ui.notify(L("Sub-Sub: no model is connected yet. Type /login and select a provider (or use the model button in subsub web).", "Sub-Sub: ainda não há um modelo ligado. Escreva /login e selecione um fornecedor (ou use o botão do modelo em subsub web)."), "warning");
|
|
301
314
|
for (const [name, err] of Object.entries(bridge.errors)) {
|
|
302
315
|
ctx.ui.notify(`Sub-Sub: the ${name} server did not start: ${err.split("\n")[0]}`, "error");
|
|
303
316
|
}
|
|
304
317
|
});
|
|
305
318
|
|
|
306
319
|
pi.on("session_shutdown", async () => {
|
|
320
|
+
if (resumeTimer) clearTimeout(resumeTimer);
|
|
307
321
|
if (deps.bridge) return;
|
|
308
322
|
const b = bridge;
|
|
309
323
|
bridge = undefined;
|
|
@@ -324,13 +338,51 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
324
338
|
});
|
|
325
339
|
|
|
326
340
|
pi.on("model_select", async (_event, ctx) => status(ctx));
|
|
341
|
+
// A provider error in plain words (quota, busy, blocked, key); the web view words its own.
|
|
342
|
+
pi.on("message_end", async (event, ctx) => {
|
|
343
|
+
const m = event.message as { role?: string; stopReason?: string; errorMessage?: string };
|
|
344
|
+
if (m.role !== "assistant" || m.stopReason !== "error") return;
|
|
345
|
+
lastError = m.errorMessage;
|
|
346
|
+
if (webView || perMinuteWait(m.errorMessage) !== undefined) return;
|
|
347
|
+
const kind = providerTrouble(m.errorMessage);
|
|
348
|
+
if (kind) ctx.ui.notify(`Sub-Sub: ${trouble[kind]}`, "warning");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// A per-minute limit (free tiers): wait as long as the provider says, then continue the task,
|
|
352
|
+
// up to three times per request of the user. Anything the user types cancels the wait.
|
|
353
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
354
|
+
if (turnStart) logUsage(cfg.usageLog, { ev: "turn", seconds: Math.round((Date.now() - turnStart) / 1000), tools: turnTools, error: providerTrouble(lastError) ?? (lastError ? "other" : undefined) });
|
|
355
|
+
turnStart = 0;
|
|
356
|
+
const wait = perMinuteWait(lastError);
|
|
357
|
+
lastError = undefined;
|
|
358
|
+
if (wait === undefined) return;
|
|
359
|
+
if (resumes >= 3) {
|
|
360
|
+
ctx.ui.notify(`Sub-Sub: ${trouble.quota}`, "warning");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
resumes++;
|
|
364
|
+
ctx.ui.notify(L(`Sub-Sub: the provider's per-minute limit was reached. Continuing in ${wait} seconds (type anything to stop waiting).`, `Sub-Sub: atingiu o limite por minuto do fornecedor. Continua dentro de ${wait} segundos (escreva qualquer coisa para deixar de esperar).`), "info");
|
|
365
|
+
resumeTimer = setTimeout(() => {
|
|
366
|
+
resumeTimer = undefined;
|
|
367
|
+
pi.sendMessage(
|
|
368
|
+
{ customType: "subsub-resume", content: "The model provider's per-minute limit was reached and has now passed. Continue the task where you stopped; do not repeat finished steps.", display: false },
|
|
369
|
+
{ triggerTurn: true },
|
|
370
|
+
);
|
|
371
|
+
}, wait * 1000);
|
|
372
|
+
});
|
|
373
|
+
|
|
327
374
|
|
|
328
375
|
// Prompt commands that the profile does not run (/lit for Reader, /review for Reader and Scholar).
|
|
329
376
|
// The input event comes before pi expands a prompt template.
|
|
330
377
|
pi.on("input", async (event, ctx) => {
|
|
378
|
+
if (resumeTimer) clearTimeout(resumeTimer);
|
|
379
|
+
resumeTimer = undefined;
|
|
380
|
+
resumes = 0;
|
|
331
381
|
const cmd = /^\/([\w-]+)(?:\s|$)/.exec(event.text.trim())?.[1];
|
|
382
|
+
// The usage log keeps the kind of request and its length, never its text.
|
|
383
|
+
if (cmd !== "subsub-refresh") logUsage(cfg.usageLog, { ev: "ask", kind: cmd ? `/${cmd}` : "message", words: event.text.trim().split(/\s+/).filter(Boolean).length });
|
|
332
384
|
if (mode === "librarian" && cmd && RESEARCH_PROMPTS.includes(cmd)) {
|
|
333
|
-
ctx.ui.notify(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, "warning");
|
|
385
|
+
ctx.ui.notify(L(`Sub-Sub: /${cmd} is part of the Researcher. Switch with /researcher.`, `Sub-Sub: /${cmd} faz parte do Investigador. Mude com /researcher.`), "warning");
|
|
334
386
|
return { action: "handled" };
|
|
335
387
|
}
|
|
336
388
|
const why = promptBlocked(event.text, profile);
|
|
@@ -339,6 +391,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
339
391
|
return { action: "handled" };
|
|
340
392
|
});
|
|
341
393
|
|
|
394
|
+
pi.on("agent_start", async () => {
|
|
395
|
+
turnStart = Date.now();
|
|
396
|
+
turnTools = 0;
|
|
397
|
+
});
|
|
398
|
+
|
|
342
399
|
pi.on("before_agent_start", async (event, ctx) => ({
|
|
343
400
|
systemPrompt: `${event.systemPrompt}\n\n${systemAddition(mode, { ...cfg, profile }, ctx.cwd)}`,
|
|
344
401
|
}));
|
|
@@ -348,6 +405,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
348
405
|
const record = (data: Record<string, unknown>) => pi.appendEntry(BENCH_ENTRY, data);
|
|
349
406
|
|
|
350
407
|
pi.on("tool_call", async (event, ctx) => {
|
|
408
|
+
turnTools++;
|
|
351
409
|
const name = event.toolName;
|
|
352
410
|
const input = (event.input ?? {}) as Record<string, unknown>;
|
|
353
411
|
// Previews and the real call must see the same, repaired arguments.
|
|
@@ -376,7 +434,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
376
434
|
const verdict = judgePath(target, buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
377
435
|
if (verdict.ok) return undefined;
|
|
378
436
|
if (!ctx.hasUI) return { block: true, reason: `Writing ${target} needs ${user}'s approval: ${verdict.why}.` };
|
|
379
|
-
const ok = await ctx.ui.confirm(`Sub-Sub: ${name} ${target}?`, `Asking because ${verdict.why}.`);
|
|
437
|
+
const ok = await ctx.ui.confirm(`Sub-Sub: ${name} ${target}?`, L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
|
|
380
438
|
return ok ? undefined : { block: true, reason: `${user} did not allow writing to that file.` };
|
|
381
439
|
}
|
|
382
440
|
if (!ctx.hasUI) {
|
|
@@ -388,7 +446,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
388
446
|
const verdict = judgePath(normalizeToolPath(input.output_path, ctx.cwd), buildPolicy({ vault: cfg.vault, cwd: ctx.cwd, serverDir: cfg.serverDir, packageDir: PACKAGE_DIR }));
|
|
389
447
|
if (!verdict.ok && /protected|hidden/.test(verdict.why)) return { block: true, reason: `Sub-Sub does not write there: ${verdict.why}.` };
|
|
390
448
|
}
|
|
391
|
-
const ok = await ctx.ui.confirm(`Sub-Sub: run ${name}?`, describeArgs(input));
|
|
449
|
+
const ok = await ctx.ui.confirm(L(`Sub-Sub: run ${name}?`, `Sub-Sub: executar ${name}?`), describeArgs(input));
|
|
392
450
|
return ok ? undefined : { block: true, reason: `${user} did not approve. Ask what to change.` };
|
|
393
451
|
}
|
|
394
452
|
// kind === "preview": the server computes the change first, then the user decides.
|
|
@@ -401,12 +459,13 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
401
459
|
return { block: true, reason: `Preview failed: ${(err as Error).message}` };
|
|
402
460
|
}
|
|
403
461
|
if (preview.isError) return { block: true, reason: preview.text };
|
|
404
|
-
const title = `Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}
|
|
405
|
-
let shown = formatPreview(name, preview.data ?? preview.text);
|
|
462
|
+
const title = L(`Sub-Sub: apply ${name.replace(/^(zotero|scholar)_/, "")}?`, `Sub-Sub: aplicar ${name.replace(/^(zotero|scholar)_/, "")}?`);
|
|
463
|
+
let shown = formatPreview(name, preview.data ?? preview.text, lang);
|
|
406
464
|
// The dialog may stay open for minutes. Before applying, compute the preview again: if the
|
|
407
465
|
// library changed meanwhile (an edit in Zotero, new items), show the new preview instead.
|
|
408
466
|
for (let round = 0; round < 3; round++) {
|
|
409
|
-
const ok = await ctx.ui.confirm(round ? `${title} (the library changed; this is the new preview)` : title, paintFor(ctx)(shown));
|
|
467
|
+
const ok = await ctx.ui.confirm(round ? `${title} ${L("(the library changed; this is the new preview)", "(a biblioteca mudou; esta é a nova pré-visualização)")}` : title, paintFor(ctx)(shown));
|
|
468
|
+
logUsage(cfg.usageLog, { ev: "change", tool: name, approved: ok });
|
|
410
469
|
if (!ok) return { block: true, reason: `${user} did not approve this change. Ask what to change; do not retry the same call.` };
|
|
411
470
|
let again;
|
|
412
471
|
try {
|
|
@@ -415,7 +474,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
415
474
|
return { block: true, reason: `Preview failed: ${(err as Error).message}` };
|
|
416
475
|
}
|
|
417
476
|
if (again.isError) return { block: true, reason: again.text };
|
|
418
|
-
const now = formatPreview(name, again.data ?? again.text);
|
|
477
|
+
const now = formatPreview(name, again.data ?? again.text, lang);
|
|
419
478
|
if (now === shown) return undefined;
|
|
420
479
|
shown = now;
|
|
421
480
|
}
|
|
@@ -456,7 +515,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
456
515
|
return { block: true, reason: `Model test: Starbuck reports only inside the vault or ${benchOut}.` };
|
|
457
516
|
}
|
|
458
517
|
if (!ctx.hasUI) return { block: true, reason: `Writing a Starbuck report to ${dir} needs ${user}'s approval: ${verdict.why}.` };
|
|
459
|
-
const ok = await ctx.ui.confirm(`Sub-Sub: write the Starbuck report to ${dir}?`, `Asking because ${verdict.why}.`);
|
|
518
|
+
const ok = await ctx.ui.confirm(L(`Sub-Sub: write the Starbuck report to ${dir}?`, `Sub-Sub: escrever o relatório do Starbuck em ${dir}?`), L(`Asking because ${verdict.why}.`, `Pergunto porque: ${verdict.why}.`));
|
|
460
519
|
return ok ? undefined : { block: true, reason: `${user} did not allow writing the report there. Ask where to put it (report_dir).` };
|
|
461
520
|
}
|
|
462
521
|
|
|
@@ -496,7 +555,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
496
555
|
description: "Sub-Sub: switch to the Librarian (manages the library: tags, imports, metadata, PDFs)",
|
|
497
556
|
handler: async (_args, ctx) => {
|
|
498
557
|
await applyMode(ctx, "librarian", true);
|
|
499
|
-
ctx.ui.notify("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "info");
|
|
558
|
+
ctx.ui.notify(L("Sub-Sub: Librarian mode. For searches and notes, switch to /researcher.", "Sub-Sub: modo Bibliotecário. Para pesquisas e notas, mude para /researcher."), "info");
|
|
500
559
|
},
|
|
501
560
|
});
|
|
502
561
|
|
|
@@ -504,7 +563,7 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
504
563
|
description: "Sub-Sub: switch to the Researcher (searches, notes, reviews, and everything the Librarian does)",
|
|
505
564
|
handler: async (_args, ctx) => {
|
|
506
565
|
await applyMode(ctx, "researcher", true);
|
|
507
|
-
ctx.ui.notify("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "info");
|
|
566
|
+
ctx.ui.notify(L("Sub-Sub: Researcher mode. It searches, writes notes and also changes the library, with a preview.", "Sub-Sub: modo Investigador. Pesquisa, escreve notas e também altera a biblioteca, com pré-visualização."), "info");
|
|
508
567
|
},
|
|
509
568
|
});
|
|
510
569
|
|
|
@@ -608,11 +667,11 @@ export async function createSubsub(pi: ExtensionAPI, deps: SubsubDeps = {}): Pro
|
|
|
608
667
|
ctx.ui.notify(preview.text, "warning");
|
|
609
668
|
return;
|
|
610
669
|
}
|
|
611
|
-
const ok = await ctx.ui.confirm("Sub-Sub: undo?", paintFor(ctx)(formatPreview("zotero_undo", preview.data)));
|
|
670
|
+
const ok = await ctx.ui.confirm(L("Sub-Sub: undo?", "Sub-Sub: anular?"), paintFor(ctx)(formatPreview("zotero_undo", preview.data, lang)));
|
|
612
671
|
if (!ok) return;
|
|
613
672
|
const res = await current().call("zotero_undo", { ...input, dry_run: false });
|
|
614
673
|
const d = (res.data ?? {}) as Record<string, unknown>;
|
|
615
|
-
ctx.ui.notify(res.isError ? res.text : `Undone: ${d.applied} item(s).`, res.isError ? "error" : "info");
|
|
674
|
+
ctx.ui.notify(res.isError ? res.text : L(`Undone: ${d.applied} item(s).`, `Anulado: ${d.applied} item(ns).`), res.isError ? "error" : "info");
|
|
616
675
|
},
|
|
617
676
|
});
|
|
618
677
|
}
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pilot's usage log: off unless the user turns it on (subsub init, or "usageLog": true).
|
|
3
|
+
* One JSON line per event in ~/.subsub/usage-log.jsonl: times, counts, commands and tool names.
|
|
4
|
+
* Never what the user wrote, the replies, titles or file names. Nothing is sent anywhere; the
|
|
5
|
+
* user reads it with `subsub usage` and sends the file to the pilot team if they want to.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export type UsageEvent =
|
|
13
|
+
| { ev: "session"; mode: string; profile: string; model?: string }
|
|
14
|
+
| { ev: "ask"; kind: string; words: number }
|
|
15
|
+
| { ev: "turn"; seconds: number; tools: number; error?: string }
|
|
16
|
+
| { ev: "change"; tool: string; approved: boolean };
|
|
17
|
+
|
|
18
|
+
export function usageFile(env: NodeJS.ProcessEnv = process.env): string {
|
|
19
|
+
return join(env.HOME ?? homedir(), ".subsub", "usage-log.jsonl");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function logUsage(on: boolean | undefined, event: UsageEvent, env: NodeJS.ProcessEnv = process.env): void {
|
|
23
|
+
if (!on) return;
|
|
24
|
+
try {
|
|
25
|
+
const file = usageFile(env);
|
|
26
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
27
|
+
appendFileSync(file, `${JSON.stringify({ t: new Date().toISOString(), ...event })}\n`, { mode: 0o600 });
|
|
28
|
+
} catch {
|
|
29
|
+
/* a log must never stop the work */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A plain summary of the log: days, sessions, requests, commands, time, library changes. */
|
|
34
|
+
export function summarizeUsage(text: string): string {
|
|
35
|
+
const rows = text
|
|
36
|
+
.split("\n")
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.flatMap((l) => {
|
|
39
|
+
try {
|
|
40
|
+
return [JSON.parse(l) as UsageEvent & { t: string }];
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
if (!rows.length) return "The usage log is empty.";
|
|
46
|
+
const days = new Set(rows.map((r) => r.t.slice(0, 10)));
|
|
47
|
+
const count = <T extends UsageEvent["ev"]>(ev: T) => rows.filter((r) => r.ev === ev) as Array<Extract<UsageEvent, { ev: T }> & { t: string }>;
|
|
48
|
+
const asks = count("ask");
|
|
49
|
+
const turns = count("turn");
|
|
50
|
+
const changes = count("change");
|
|
51
|
+
const kinds = new Map<string, number>();
|
|
52
|
+
for (const a of asks) kinds.set(a.kind, (kinds.get(a.kind) ?? 0) + 1);
|
|
53
|
+
const minutes = Math.round(turns.reduce((s, r) => s + r.seconds, 0) / 60);
|
|
54
|
+
const errors = turns.filter((r) => r.error).length;
|
|
55
|
+
const lines = [
|
|
56
|
+
`From ${rows[0].t.slice(0, 10)} to ${rows.at(-1)!.t.slice(0, 10)}: Sub-Sub used on ${days.size} day(s), in ${count("session").length} session(s).`,
|
|
57
|
+
`Requests: ${asks.length} (${[...kinds].sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ${n}`).join(", ")}).`,
|
|
58
|
+
`Time Sub-Sub spent working: ${minutes} minute(s), with ${turns.reduce((s, r) => s + r.tools, 0)} tool call(s) and ${errors} error(s).`,
|
|
59
|
+
`Library changes: ${changes.filter((c) => c.approved).length} approved, ${changes.filter((c) => !c.approved).length} declined.`,
|
|
60
|
+
];
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function usageMain(env: NodeJS.ProcessEnv = process.env): number {
|
|
65
|
+
const file = usageFile(env);
|
|
66
|
+
if (!existsSync(file)) {
|
|
67
|
+
console.log(`No usage log yet (${file}). To keep one for the pilot, type subsub init and turn it on, or set "usageLog": true in the settings.`);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
console.log(summarizeUsage(readFileSync(file, "utf8")));
|
|
71
|
+
console.log("");
|
|
72
|
+
console.log(`The log: ${file}`);
|
|
73
|
+
console.log("It holds times, counts, commands and tool names, never what you wrote or what Sub-Sub replied. To share it with the pilot team, send this file.");
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
package/src/web.ts
CHANGED
|
@@ -22,8 +22,9 @@ import { homedir } from "node:os";
|
|
|
22
22
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
23
23
|
import { StringDecoder } from "node:string_decoder";
|
|
24
24
|
import { agentDirFor, chooseCwd, PACKAGE_DIR, packageVersion } from "./cli.ts";
|
|
25
|
-
import { configPath, expand, loadConfig, readConfigFile, saveConfig, type SubsubConfig, withStarbuck } from "./config.ts";
|
|
25
|
+
import { configPath, expand, loadConfig, RECOMMENDED_MODELS, readConfigFile, saveConfig, type SubsubConfig, uiLanguage, withStarbuck } from "./config.ts";
|
|
26
26
|
import { TESTED_MODELS } from "./init.ts";
|
|
27
|
+
import { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
|
|
27
28
|
import { obsidianRoot } from "./layout.ts";
|
|
28
29
|
import { realish, within } from "./paths.ts";
|
|
29
30
|
|
|
@@ -55,42 +56,11 @@ export function parseWebArgs(args: string[]): WebOptions {
|
|
|
55
56
|
return o;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
|
|
59
|
-
export function uiLanguage(language: string | undefined): "pt" | "en" {
|
|
60
|
-
return /portug|^pt\b|^pt-/i.test(language ?? "") ? "pt" : "en";
|
|
61
|
-
}
|
|
59
|
+
export { uiLanguage } from "./config.ts";
|
|
62
60
|
|
|
63
61
|
// ---------------------------------------------------------------- providers (API keys)
|
|
64
62
|
|
|
65
|
-
export
|
|
66
|
-
{ id: "opencode-go", label: "OpenCode Go", url: "https://opencode.ai/auth" },
|
|
67
|
-
{ id: "openrouter", label: "OpenRouter", url: "https://openrouter.ai/keys" },
|
|
68
|
-
{ id: "anthropic", label: "Anthropic", url: "https://console.anthropic.com/settings/keys" },
|
|
69
|
-
{ id: "openai", label: "OpenAI", url: "https://platform.openai.com/api-keys" },
|
|
70
|
-
{ id: "google", label: "Google Gemini", url: "https://aistudio.google.com/apikey" },
|
|
71
|
-
{ id: "mistral", label: "Mistral", url: "https://console.mistral.ai/api-keys" },
|
|
72
|
-
{ id: "deepseek", label: "DeepSeek", url: "https://platform.deepseek.com/api_keys" },
|
|
73
|
-
];
|
|
74
|
-
|
|
75
|
-
/** Save an API key the way pi's /login does: auth.json in the agent folder, readable only by the user. */
|
|
76
|
-
export function saveApiKey(agentDir: string, provider: string, key: string): void {
|
|
77
|
-
if (!KEY_PROVIDERS.some((p) => p.id === provider)) throw new Error(`Unknown provider: ${provider}`);
|
|
78
|
-
const clean = key.trim();
|
|
79
|
-
if (!clean || /\s/.test(clean) || clean.length > 400) throw new Error("That does not look like an API key.");
|
|
80
|
-
mkdirSync(agentDir, { recursive: true });
|
|
81
|
-
const file = join(agentDir, "auth.json");
|
|
82
|
-
let data: Record<string, unknown> = {};
|
|
83
|
-
if (existsSync(file)) {
|
|
84
|
-
try {
|
|
85
|
-
data = JSON.parse(readFileSync(file, "utf8")) ?? {};
|
|
86
|
-
} catch {
|
|
87
|
-
throw new Error(`${file} is not valid JSON; fix it or type /login in the terminal.`);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
data[provider] = { type: "api_key", key: clean };
|
|
91
|
-
// Write through a symlink (a shared pi auth.json) rather than replacing it.
|
|
92
|
-
writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
93
|
-
}
|
|
63
|
+
export { KEY_PROVIDERS, saveApiKey } from "./keys.ts";
|
|
94
64
|
|
|
95
65
|
// ---------------------------------------------------------------- pi in RPC mode
|
|
96
66
|
|
|
@@ -597,7 +567,7 @@ export async function startWeb(opts: WebOptions, env: NodeJS.ProcessEnv = proces
|
|
|
597
567
|
}
|
|
598
568
|
case "GET /api/models": {
|
|
599
569
|
const data = (await agent.request({ type: "get_available_models" })) as { models?: unknown[] } | unknown[];
|
|
600
|
-
return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []) });
|
|
570
|
+
return send(res, 200, { models: Array.isArray(data) ? data : (data.models ?? []), recommended: RECOMMENDED_MODELS });
|
|
601
571
|
}
|
|
602
572
|
case "POST /api/model": {
|
|
603
573
|
if (busy || switching) return send(res, 409, { error: "busy" });
|