@gafj/gafj 0.1.4 → 0.1.6

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/bridge/byo_key.js CHANGED
@@ -40,8 +40,8 @@ async function runPacket(homeDir, id, { packet, fetchImpl }) {
40
40
  const { renderPasteText } = require("./paste");
41
41
  const started = Date.now();
42
42
  try {
43
- const r = await adapter.complete({ provider: p, system: "You are a careful writing engine. Reply with exactly one fenced json block matching OUTPUT SCHEMA and nothing else.", user: renderPasteText(packet), max_tokens: 8000, fetch: fetchImpl });
44
- return { content: r.text, provider: p.kind, model: r.model || p.model, tokens_in: r.tokens_in, tokens_out: r.tokens_out, latency_ms: Date.now() - started };
43
+ const r = await adapter.complete({ provider: p, system: "You are a careful writing engine. Reply with exactly one fenced json block matching OUTPUT SCHEMA and nothing else.", user: renderPasteText(packet), max_tokens: 16000, fetch: fetchImpl });
44
+ return { content: r.text, provider: p.kind, model: r.model || p.model, tokens_in: r.tokens_in, tokens_out: r.tokens_out, latency_ms: Date.now() - started, stop_reason: r.stop_reason || null };
45
45
  } catch (e) {
46
46
  throw Object.assign(new Error(scrub(e.message, p.api_key)), { status: 502 });
47
47
  }
@@ -10,7 +10,7 @@ async function complete({ provider, system, user, max_tokens = 4000, fetch }) {
10
10
  const data = await post(fetch, `${base}/v1/messages`, { "x-api-key": provider.api_key || "", "anthropic-version": "2023-06-01" },
11
11
  { model, max_tokens, system, messages: [{ role: "user", content: user }] });
12
12
  const text = (data.content || []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
13
- return { text, model: data.model || model, tokens_in: data.usage && data.usage.input_tokens, tokens_out: data.usage && data.usage.output_tokens };
13
+ return { text, model: data.model || model, tokens_in: data.usage && data.usage.input_tokens, tokens_out: data.usage && data.usage.output_tokens, stop_reason: data.stop_reason || null };
14
14
  }
15
15
 
16
16
  module.exports = { complete, DEFAULT_URL, DEFAULT_MODEL };
package/http/api.js CHANGED
@@ -151,6 +151,14 @@ route("GET", "/api/kb/questions", (c, p, b, q) => discover.listQuestions(c.db, c
151
151
  route("POST", "/api/kb/questions/:id/answer", (c, p, b) => discover.answerQuestion(c.db, { candidate_id: c.candidate_id, question_id: p.id, draft: need(b, "draft"), now: c.now(), actor: ACTOR("answer_question") }));
152
152
  route("POST", "/api/kb/questions/:id/dismiss", (c, p) => discover.dismissQuestion(c.db, { candidate_id: c.candidate_id, question_id: p.id, now: c.now(), actor: ACTOR("dismiss_question") }));
153
153
 
154
+ // usage: tokens per operation from the attempts the app recorded; the pricing test reads this
155
+ route("GET", "/api/usage", (c) => ({
156
+ by_op: c.db.prepare(`SELECT r.operation AS op, count(*) AS attempts, sum(CASE WHEN a.result = 'passed' THEN 1 ELSE 0 END) AS passed,
157
+ coalesce(sum(a.tokens_in), 0) AS tokens_in, coalesce(sum(a.tokens_out), 0) AS tokens_out, coalesce(avg(a.latency_ms), 0) AS avg_ms, max(a.model) AS model
158
+ FROM ai_attempt a JOIN ai_run r ON r.id = a.ai_run_id WHERE r.candidate_id = ? AND a.tokens_in IS NOT NULL GROUP BY r.operation ORDER BY r.operation`).all(c.candidate_id),
159
+ since: c.db.prepare("SELECT min(a.started_at) AS s FROM ai_attempt a JOIN ai_run r ON r.id = a.ai_run_id WHERE r.candidate_id = ? AND a.tokens_in IS NOT NULL").get(c.candidate_id).s,
160
+ }));
161
+
154
162
  // guided setup (first run)
155
163
  const setup = require("../store/setup");
156
164
  route("GET", "/api/setup", (c) => setup.setupView(c.db, c.home, c.candidate_id));
@@ -194,18 +202,24 @@ route("POST", "/api/run/:op", async (c, p, b, q) => {
194
202
  if (cfg.route !== "byo_key" && cfg.route !== "credits") throw bad("the active route is neither byo_key nor credits; set it in settings", 409);
195
203
  const routeName = cfg.route;
196
204
  let reply;
205
+ // resolve the provider before a run row exists: one saved provider is the active one even if nobody clicked the radio
206
+ let providerId = null;
207
+ if (routeName === "byo_key") {
208
+ providerId = b.provider_id || cfg.active_provider_id || ((cfg.providers || []).length === 1 ? cfg.providers[0].id : null);
209
+ if (!providerId) throw bad((cfg.providers || []).length ? "pick the active provider in Settings" : "add a provider key in Settings", 409);
210
+ if (!cfg.active_provider_id && !b.provider_id) settings.putSettings(c.db, c.home, c.candidate_id, { active_provider_id: providerId }, { now: c.now(), actor: ACTOR("run") });
211
+ }
197
212
  if (p.op === "discover") require("../store/discover").assertCanOpen(c.db, c.candidate_id);
198
213
  const opened = openRun(c.db, { op: p.op, candidate_id: c.candidate_id, interview_id: q.get("interview_id") || undefined, posting_id: q.get("posting_id") || undefined, source_document_id: q.get("source_document_id") || undefined, route: routeName, actor: ACTOR("run"), now: c.now() });
199
214
  if (routeName === "credits") {
200
215
  const { runPacket } = require("../bridge/credits");
201
216
  reply = await runPacket(c.home, { packet: opened.packet, fetchImpl: c.fetch, auth: c.relayAuth });
202
217
  } else {
203
- const providerId = b.provider_id || cfg.active_provider_id;
204
- if (!providerId) throw bad("no active provider", 409);
205
218
  const { runPacket } = require("../bridge/byo_key");
206
219
  reply = await runPacket(c.home, providerId, { packet: opened.packet, fetchImpl: c.fetch });
207
220
  }
208
221
  const r = saveReply(c.db, { run_id: opened.run_id, content: reply.content, route: routeName, actor: ACTOR("run"), now: c.now(), provider: reply.provider, model: reply.model, tokens_in: reply.tokens_in, tokens_out: reply.tokens_out, latency_ms: reply.latency_ms });
222
+ if (r.status !== "passed" && reply.stop_reason === "max_tokens") r.errors = [...(r.errors || []), "the reply was cut off at the token limit"];
209
223
  return reply.credits_left === undefined ? r : { ...r, charged: reply.charged, credits_left: reply.credits_left };
210
224
  });
211
225
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gafj/gafj",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "GAF-J: a local campaign engine for job search. One SQLite file, one ingest door, your own AI subscription.",
5
5
  "homepage": "https://gaf-j.com",
6
6
  "repository": {
@@ -145,7 +145,8 @@ function saveExtraction(db, { run_id, content, route, actor, now, provider, mode
145
145
  const ex = extractJson(content);
146
146
  let result;
147
147
  let parsed = null;
148
- if (ex.error) result = { accepted: false, errors: ["schema: " + ex.error], pending_ids: [] };
148
+ // the reply is never stored; on a schema miss its opening is echoed back to the caller once so the failure can be read
149
+ if (ex.error) result = { accepted: false, errors: ["schema: " + ex.error + (typeof content === "string" && content.trim() ? ` (reply began: "${content.trim().slice(0, 160).replace(/\s+/g, " ")}")` : " (the reply was empty)")], pending_ids: [] };
149
150
  else { parsed = ex.value; result = { accepted: false, errors: [], pending_ids: [] }; }
150
151
  const attemptId = ulid(Date.parse(now) + 100 + attemptNo);
151
152
  d.prepare(`INSERT INTO ai_attempt (id, ai_run_id, attempt_no, provider, model, tokens_in, tokens_out, latency_ms, output_hash, result, error_code, started_at, finished_at)
package/store/setup.js CHANGED
@@ -21,7 +21,10 @@ function setupView(db, homeDir, candidate_id) {
21
21
  const confirmed = one("SELECT count(*) c FROM accomplishment WHERE candidate_id = ?", candidate_id);
22
22
  const questions = one("SELECT count(*) c FROM kb_question WHERE candidate_id = ?", candidate_id);
23
23
  const postings = one("SELECT count(*) c FROM posting WHERE candidate_id = ?", candidate_id);
24
- const aiChosen = !!(cfg.setup && cfg.setup.ai_chosen);
24
+ // byo_key counts only with a usable provider: the active one, or the single one saved
25
+ const providers = cfg.providers || [];
26
+ const usable = cfg.route !== "byo_key" || !!(cfg.active_provider_id && providers.some((x) => x.id === cfg.active_provider_id)) || providers.length === 1;
27
+ const aiChosen = !!(cfg.setup && cfg.setup.ai_chosen) && usable;
25
28
  const steps = [
26
29
  { key: "ai", title: "Tell GAF-J which AI you use", done: aiChosen, detail: aiChosen ? `Using ${ROUTE_LABEL[cfg.route] || cfg.route}` : null },
27
30
  { key: "upload", title: "Upload your resumes", done: sources > 0, detail: sources ? `${sources} file${sources === 1 ? "" : "s"} uploaded` : null },
package/ui/app.css CHANGED
@@ -170,3 +170,15 @@ mark { background: var(--good-bg); color: var(--good-bright); border-radius: 3px
170
170
  .welcome-body p { color: var(--muted); max-width: 66ch; }
171
171
  .welcome-body .card { border: 0; padding: 0; background: transparent; }
172
172
  .ai-options button.picked { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
173
+ .welcome-head .links { margin-left: auto; display: flex; gap: 14px; }
174
+ .stepper li.can { cursor: pointer; }
175
+ .stepper li.can:hover { color: var(--accent); }
176
+ .welcome-nav { margin-top: 16px; justify-content: space-between; }
177
+ /* a switch: one provider is on at a time; off everywhere is a warning, not a state to sit in */
178
+ .switch { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
179
+ .switch input { position: absolute; opacity: 0; width: 0; height: 0; }
180
+ .switch .track { width: 34px; height: 18px; border-radius: 100px; background: var(--line-2); position: relative; transition: background .15s; }
181
+ .switch .track::after { content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--surface); transition: left .15s; box-shadow: 0 1px 2px #0003; }
182
+ .switch input:checked + .track { background: var(--accent); }
183
+ .switch input:checked + .track::after { left: 18px; }
184
+ .switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
package/ui/app.js CHANGED
@@ -63,7 +63,7 @@ function App() {
63
63
  useEffect(() => { if (gate === false) return; api.get("/api/setup").then((s) => setGate(!s.complete && !s.dismissed)).catch(() => setGate(false)); }, [live]);
64
64
  const seg = route.path.split("/").filter(Boolean);
65
65
  if (gate === null) return html`<div class="welcome"><p class="muted">loading</p></div>`;
66
- if (gate) return html`<${Welcome} live=${live} onDone=${() => setGate(false)} />`;
66
+ if (gate && seg[0] !== "settings") return html`<${Welcome} live=${live} onDone=${() => setGate(false)} />`;
67
67
  let screen;
68
68
  if (seg[0] === "applications" && seg[1]) screen = html`<${Application} id=${seg[1]} live=${live} />`;
69
69
  else if (seg[0] === "applications") screen = html`<${Applications} live=${live} />`;
@@ -72,7 +72,7 @@ function App() {
72
72
  else if (seg[0] === "documents" && seg[1]) screen = html`<${Document} id=${seg[1]} live=${live} />`;
73
73
  else if (seg[0] === "postings") screen = html`<${Postings} id=${seg[1]} live=${live} />`;
74
74
  else if (seg[0] === "kb") screen = html`<${KB} live=${live} />`;
75
- else if (seg[0] === "settings") screen = html`<${Settings} live=${live} />`;
75
+ else if (seg[0] === "settings") screen = html`<div>${gate ? html`<p class="small"><a href="#/">← back to setup</a></p>` : ""}<${Settings} live=${live} /></div>`;
76
76
  else screen = html`<${Dashboard} live=${live} />`;
77
77
  return html`<div class="layout"><${Rail} route=${route} me=${me} /><main class="main">${screen}</main></div>`;
78
78
  }
@@ -13,8 +13,9 @@ export function Settings({ live }) {
13
13
  const [msg, setMsg] = useState("");
14
14
  const [prov, setProv] = useState(null);
15
15
  const [mcp, setMcp] = useState(null);
16
+ const [usage, setUsage] = useState(null);
16
17
  const inst = useInstall();
17
- const load = () => api.get("/api/settings").then((x) => { setS(x); setErr(""); }).catch((e) => setErr(e.message));
18
+ const load = () => Promise.all([api.get("/api/settings"), api.get("/api/usage").catch(() => null)]).then(([x, u]) => { setS(x); setUsage(u); setErr(""); }).catch((e) => setErr(e.message));
18
19
  useEffect(() => { load(); }, [live]);
19
20
  const put = async (patch) => { setMsg(""); try { setS(await api.put("/api/settings", patch)); setMsg("saved"); } catch (e) { setErr(e.message); } };
20
21
  const post = async (path, body, label) => { setMsg(""); try { const r = await api.post(path, body); setMsg(label ? `${label}: ${r.file || r.message || JSON.stringify(r)}` : JSON.stringify(r)); await load(); } catch (e) { setErr(e.message); } };
@@ -43,8 +44,10 @@ export function Settings({ live }) {
43
44
  ${mcp ? html`<div><p class="small">Claude Desktop, merge into mcpServers:</p><textarea readonly value=${JSON.stringify(mcp.desktop, null, 2)}></textarea><p class="small">Claude Code:</p><textarea readonly value=${mcp.code}></textarea></div>` : ""}
44
45
  </div>
45
46
  ${s.hosted ? "" : html`<h2>My providers</h2>
46
- <div class="card"><ul class="list">${s.providers.map((p) => html`<li key=${p.id}>
47
- <label><input type="radio" name="active" checked=${s.active_provider_id === p.id} onChange=${() => put({ active_provider_id: p.id })} /></label>
47
+ <div class="card">
48
+ ${s.providers.length && !s.providers.some((p) => p.id === s.active_provider_id) ? html`<div class="block warn"><b>No provider is switched on.</b> Turn one on below, or nothing can run. Exactly one is on at a time.</div>` : ""}
49
+ <ul class="list">${s.providers.map((p) => html`<li key=${p.id}>
50
+ <label class="switch" title=${s.active_provider_id === p.id ? "on: this provider runs your documents" : "off"}><input type="checkbox" checked=${s.active_provider_id === p.id} onChange=${(e) => put({ active_provider_id: e.target.checked ? p.id : null })} /><span class="track"></span><span class="small">${s.active_provider_id === p.id ? "on" : "off"}</span></label>
48
51
  <b>${p.label}</b> <span class="tag">${p.kind}</span> <span class="muted">${p.model || ""}</span> <span class="mono small">${p.key || "no key"}</span> <span class="muted small">${p.base_url || ""}</span>
49
52
  ${p.last_test ? html`<span class=${"tag " + (p.last_test.ok ? "passed" : "blocked")}>${p.last_test.ok ? "ok" : "failed"}</span>` : ""}
50
53
  <button class="small" onClick=${() => post(`/api/settings/providers/${p.id}/test`, {}, "test")}>test</button>
@@ -87,6 +90,14 @@ export function Settings({ live }) {
87
90
  </form>`}
88
91
  ${s.credits && s.credits.last_balance ? html`<p class="small">${s.credits.last_balance.available} credits available (${s.credits.last_balance.reserved} held), checked ${s.credits.last_balance.at}</p>` : ""}
89
92
  </div>
93
+ <h2>Usage</h2>
94
+ <div class="card">
95
+ ${usage && usage.by_op.length ? html`<table class="cats"><thead><tr><th>operation</th><th>runs</th><th>passed</th><th>tokens in</th><th>tokens out</th><th>avg seconds</th><th>model</th></tr></thead><tbody>
96
+ ${usage.by_op.map((u) => html`<tr key=${u.op}><td>${u.op}</td><td>${u.attempts}</td><td>${u.passed}</td><td>${u.tokens_in.toLocaleString()}</td><td>${u.tokens_out.toLocaleString()}</td><td>${(u.avg_ms / 1000).toFixed(1)}</td><td class="small muted">${u.model || ""}</td></tr>`)}
97
+ <tr><td><b>total</b></td><td>${usage.by_op.reduce((n, u) => n + u.attempts, 0)}</td><td>${usage.by_op.reduce((n, u) => n + u.passed, 0)}</td><td><b>${usage.by_op.reduce((n, u) => n + u.tokens_in, 0).toLocaleString()}</b></td><td><b>${usage.by_op.reduce((n, u) => n + u.tokens_out, 0).toLocaleString()}</b></td><td></td><td></td></tr>
98
+ </tbody></table><p class="small muted">Counted from every run through your own provider or credits since ${usage.since ? new Date(usage.since).toLocaleDateString() : "the start"}. Paste runs are not counted; the app never sees those token counts. Multiply by your provider's price per million tokens for the cost.</p>`
99
+ : html`<p class="small muted">Nothing counted yet. Runs through your own provider key or credits record their token counts here.</p>`}
100
+ </div>
90
101
  <h2>Bullet register</h2>
91
102
  <div class="card">
92
103
  ${[["result_first", "Result first (RAS): the figure leads, the action follows. \"29.3% off annual freight by rebidding 40 lanes.\""], ["action_first", "Action first: the verb leads, the result closes. \"Rebid 40 lanes, taking 29.3% off annual freight.\""], ["posting", "Let the posting decide: verb-led responsibility lines get action first, outcome-led lines get result first; the AI says which in its notes."]].map(([r, label]) => html`<div key=${r}><label><input type="radio" name="register" checked=${(h.bullet_register || "result_first") === r} onChange=${() => hr({ bullet_register: r })} /> ${label}</label></div>`)}
@@ -84,20 +84,22 @@ export function Welcome({ live, onDone }) {
84
84
  const [modal, setModal] = useState(null);
85
85
  const [answering, setAnswering] = useState(null);
86
86
  const [busy, setBusy] = useState("");
87
+ const [view, setView] = useState(null); // a step the user went back to; null follows the next undone step
87
88
  const load = () => Promise.all([api.get("/api/setup"), api.get("/api/onboarding"), api.get("/api/kb"), api.get("/api/me")]).then(([s, o, k, m]) => { setSetup(s); setOb(o); setKb(k); setMe(m); if (s.complete) onDone(); }).catch((e) => setErr(e.message));
88
89
  useEffect(() => { load(); }, [live]);
89
90
  const post = async (path, body) => { try { setErr(""); await api.post(path, body || {}); await load(); } catch (e) { setErr(e.message); } };
90
91
  if (!setup || !ob || !kb || !me) return html`<div class="welcome"><p class="muted">loading</p></div>`;
91
92
  const direct = me.route === "byo_key" || me.route === "credits";
92
- const step = setup.next;
93
- const idx = setup.steps.findIndex((x) => x.key === step);
93
+ const nextIdx = setup.steps.findIndex((x) => x.key === setup.next);
94
+ const idx = view !== null && view <= Math.max(nextIdx, 0) ? view : nextIdx;
95
+ const step = setup.steps[idx].key;
94
96
  const discover = async () => {
95
97
  if (!direct) { setModal("discover"); return; }
96
98
  setBusy("asking"); try { const r = await api.post("/api/run/discover", {}); if (r.status !== "passed") setErr("refused: " + (r.errors || []).join("; ")); await load(); } catch (e) { setErr(e.message); } setBusy("");
97
99
  };
98
100
  const body = {
99
101
  ai: html`<${StepAi} s=${setup} reload=${load} />`,
100
- upload: html`<div><p>Every resume you have, old ones too: Word, PDF, Markdown, or plain text. The text is read on this PC. Nothing is uploaded anywhere.</p><${Onboarding} d=${ob} reload=${load} setErr=${setErr} route=${me.route} compact=${true} /></div>`,
102
+ upload: html`<div><p>Your resumes: Word, PDF, Markdown, or plain text. Five to ten of your most different versions is plenty; near-identical copies only add duplicates to merge. The text is read on this PC. Nothing is uploaded anywhere.</p><${Onboarding} d=${ob} reload=${load} setErr=${setErr} route=${me.route} compact=${true} /></div>`,
101
103
  extract: html`<div><p>${direct ? "Your AI reads each file and proposes records where every number and phrase is quoted from the file; anything it cannot quote is refused. One click runs the whole pile." : "For each file, Extract opens a packet: copy it into your chat, paste the JSON reply back. Your AI proposes records where every number and phrase is quoted from the file."}</p><${Onboarding} d=${ob} reload=${load} setErr=${setErr} route=${me.route} compact=${true} /></div>`,
102
104
  confirm: html`<div><p>These are the records your AI proposed. Confirm the ones you can stand behind in an interview, edit what is off, dismiss the rest. Only confirmed records can ever appear in a document.</p>
103
105
  <ul class="list">${kb.pending.map((p) => html`<li key=${p.pending_id}><div><b>${p.draft.title}</b> <span class="muted">${p.draft.company}, ${p.draft.role}</span>
@@ -114,10 +116,11 @@ export function Welcome({ live, onDone }) {
114
116
  };
115
117
  return html`<div class="welcome">
116
118
  <div class="welcome-head"><img src="/icon-192.png" alt="" width="40" height="40" /><div><b>Welcome to GAF-J${me.name ? ", " + me.name : ""}</b><div class="small muted">Six short steps, then the app is yours.</div></div>
117
- <a class="small" href="#/" onClick=${async (e) => { e.preventDefault(); await api.post("/api/setup/dismiss", {}); onDone(); }}>skip for now</a></div>
118
- <ol class="stepper">${setup.steps.map((st, i) => html`<li key=${st.key} class=${st.done ? "done" : i === idx ? "now" : ""}><span class="n mono">${st.done ? "✓" : i + 1}</span>${st.title}</li>`)}</ol>
119
+ <span class="links"><a class="small" href="#/settings">Settings</a><a class="small" href="#/" onClick=${async (e) => { e.preventDefault(); await api.post("/api/setup/dismiss", {}); onDone(); }}>skip for now</a></span></div>
120
+ <ol class="stepper">${setup.steps.map((st, i) => html`<li key=${st.key} class=${(st.done ? "done" : "") + (i === idx ? " now" : "") + (i <= nextIdx ? " can" : "")} onClick=${() => { if (i <= nextIdx) setView(i); }} title=${i <= nextIdx ? "open this step" : ""}><span class="n mono">${st.done ? "✓" : i + 1}</span>${st.title}</li>`)}</ol>
119
121
  ${err ? html`<p class="error">${err}</p>` : ""}
120
- <div class="card welcome-body"><h3>${idx + 1}. ${setup.steps[idx].title}</h3>${body[step]}</div>
122
+ <div class="card welcome-body"><h3>${idx + 1}. ${setup.steps[idx].title}</h3>${body[step]}
123
+ <div class="row welcome-nav">${idx > 0 ? html`<button class="small" onClick=${() => setView(idx - 1)}>← Back</button>` : ""}${idx < nextIdx ? html`<button class="small" onClick=${() => setView(null)}>Continue →</button>` : ""}</div></div>
121
124
  ${modal === "discover" ? html`<${PacketModal} op="discover" label="Discovery questions" target=${{}} onClose=${() => setModal(null)} onSaved=${load} />` : ""}
122
125
  </div>`;
123
126
  }