@gafj/gafj 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -0
- package/bin/cli.js +628 -0
- package/bridge/byo_key.js +50 -0
- package/bridge/credits.js +72 -0
- package/bridge/paste.js +44 -0
- package/bridge/providers/anthropic.js +16 -0
- package/bridge/providers/gemini.js +15 -0
- package/bridge/providers/index.js +39 -0
- package/bridge/providers/openai_compatible.js +17 -0
- package/core/extract.js +55 -0
- package/core/generators/excerpt.js +64 -0
- package/core/generators/packet.js +197 -0
- package/core/generators/serialize.js +56 -0
- package/core/generators/slice.js +61 -0
- package/core/house_rules.js +49 -0
- package/core/kb_import.js +257 -0
- package/core/migrations/002-profile.sql +3 -0
- package/core/migrations/003-discover.sql +49 -0
- package/core/parsers.js +79 -0
- package/core/practice.js +75 -0
- package/core/provenance.js +90 -0
- package/core/qr.js +185 -0
- package/core/rules/categories.md +5 -0
- package/core/rules/deep_answers.md +7 -0
- package/core/rules/discover.md +30 -0
- package/core/rules/extract.md +31 -0
- package/core/rules/followup.md +9 -0
- package/core/rules/index.js +32 -0
- package/core/rules/onepager.md +15 -0
- package/core/rules/practice.md +10 -0
- package/core/rules/prep.md +47 -0
- package/core/rules/resume.md +49 -0
- package/core/rules/rubric.md +120 -0
- package/core/schema.sql +342 -0
- package/core/schemas/categories.js +16 -0
- package/core/schemas/cover.js +10 -0
- package/core/schemas/deep_answers.js +16 -0
- package/core/schemas/discover.js +19 -0
- package/core/schemas/extract.js +32 -0
- package/core/schemas/followup.js +10 -0
- package/core/schemas/index.js +32 -0
- package/core/schemas/lang.js +111 -0
- package/core/schemas/onepager.js +26 -0
- package/core/schemas/practice.js +18 -0
- package/core/schemas/prep.js +15 -0
- package/core/schemas/resume.js +25 -0
- package/core/scoring.js +586 -0
- package/core/text.js +98 -0
- package/core/tracker_map.js +94 -0
- package/core/ulid.js +38 -0
- package/core/validators/highlight_body_match.js +49 -0
- package/core/validators/index.js +43 -0
- package/core/validators/metric_pairing.js +52 -0
- package/core/validators/no_confabulated_history.js +63 -0
- package/core/validators/no_dashes.js +31 -0
- package/core/validators/no_derived_tenure.js +60 -0
- package/core/validators/style_config.js +18 -0
- package/core/validators/verb_ladder.js +51 -0
- package/core/verbs.js +43 -0
- package/http/api.js +260 -0
- package/http/guard.js +106 -0
- package/http/headers.js +17 -0
- package/http/hosted.js +273 -0
- package/http/identity.js +70 -0
- package/http/server.js +136 -0
- package/http/sse.js +41 -0
- package/mcp/server.js +25 -0
- package/mcp/tools.js +237 -0
- package/package.json +59 -0
- package/store/backup.js +63 -0
- package/store/backup_dir.js +60 -0
- package/store/config.js +32 -0
- package/store/context.js +74 -0
- package/store/db.js +37 -0
- package/store/discover.js +120 -0
- package/store/events.js +28 -0
- package/store/export_xlsx.js +62 -0
- package/store/home.js +36 -0
- package/store/import_kb.js +88 -0
- package/store/import_tracker.js +195 -0
- package/store/ingest.js +140 -0
- package/store/kb.js +202 -0
- package/store/log.js +88 -0
- package/store/migrate.js +55 -0
- package/store/onboarding.js +267 -0
- package/store/packets.js +110 -0
- package/store/practice.js +52 -0
- package/store/proposals.js +119 -0
- package/store/reads.js +110 -0
- package/store/render.js +111 -0
- package/store/reply.js +19 -0
- package/store/review.js +64 -0
- package/store/scoring.js +134 -0
- package/store/settings.js +142 -0
- package/store/snapshot.js +88 -0
- package/store/transitions.js +391 -0
- package/store/tx.js +71 -0
- package/store/tz.js +24 -0
- package/ui/app.css +137 -0
- package/ui/app.js +75 -0
- package/ui/fonts/plexmono-400.woff2 +0 -0
- package/ui/fonts/plexmono-500.woff2 +0 -0
- package/ui/fonts/plexmono-600.woff2 +0 -0
- package/ui/fonts/spacegrotesk-var.woff2 +0 -0
- package/ui/hosted/signin.css +8 -0
- package/ui/hosted/signin.html +30 -0
- package/ui/hosted/signin.js +28 -0
- package/ui/icon-192.png +0 -0
- package/ui/icon-512.png +0 -0
- package/ui/index.html +20 -0
- package/ui/lib.js +92 -0
- package/ui/manifest.webmanifest +18 -0
- package/ui/screens/application.js +73 -0
- package/ui/screens/dashboard.js +37 -0
- package/ui/screens/document.js +64 -0
- package/ui/screens/interview.js +85 -0
- package/ui/screens/kb.js +142 -0
- package/ui/screens/packet.js +122 -0
- package/ui/screens/postings.js +92 -0
- package/ui/screens/settings.js +115 -0
- package/ui/vendor/preact.mjs +1 -0
package/store/reads.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read models for the screens. Plain queries, no clock, no writes. Every
|
|
5
|
+
* read of a person goes through canonical_person so a merged id resolves.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const { canonicalPerson } = require("./transitions");
|
|
9
|
+
const { serializeRecord } = require("../core/generators/serialize");
|
|
10
|
+
const { loadSlice } = require("./context");
|
|
11
|
+
|
|
12
|
+
function person(db, id) {
|
|
13
|
+
const p = canonicalPerson(db, id);
|
|
14
|
+
return p ? { person_id: p.id, name: p.name, title: p.title, org: p.org, intel_text: p.intel_text, links: JSON.parse(p.links_json || "[]") } : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function docRow(d) {
|
|
18
|
+
const gate = JSON.parse(d.gate_result_json || "{}");
|
|
19
|
+
return { document_id: d.id, kind: d.kind, status: d.status, route: d.route, created_at: d.created_at, rendered_rel_path: d.rendered_rel_path,
|
|
20
|
+
interview_id: d.interview_id, posting_id: d.posting_id, application_id: d.application_id,
|
|
21
|
+
blocks: gate.blocks || [], warnings: JSON.parse(d.warnings_json || "[]"), ai_attempt_id: d.ai_attempt_id };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function documentsFor(db, { posting_id, application_id, interview_id }) {
|
|
25
|
+
const rows = db.prepare(`SELECT d.* FROM document d LEFT JOIN interview i ON i.id = d.interview_id
|
|
26
|
+
WHERE (? IS NOT NULL AND d.posting_id = ?) OR (? IS NOT NULL AND (d.application_id = ? OR i.application_id = ?)) OR (? IS NOT NULL AND d.interview_id = ?)
|
|
27
|
+
ORDER BY d.created_at DESC`).all(posting_id || null, posting_id || null, application_id || null, application_id || null, application_id || null, interview_id || null, interview_id || null);
|
|
28
|
+
return rows.map(docRow);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function listApplications(db, candidate_id) {
|
|
32
|
+
return db.prepare(`SELECT a.id AS application_id, a.status, a.applied_at, a.decision_expected_at, p.id AS posting_id, p.company, p.title, p.taken_down_at, s.fit_score, s.verdict,
|
|
33
|
+
(SELECT count(*) FROM interview i WHERE i.application_id = a.id AND i.status = 'scheduled') AS scheduled
|
|
34
|
+
FROM application a JOIN posting p ON p.id = a.posting_id LEFT JOIN scoring s ON s.id = a.scoring_id WHERE p.candidate_id = ? ORDER BY a.applied_at DESC, p.captured_at DESC`).all(candidate_id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function interviewRow(db, i) {
|
|
38
|
+
const people = db.prepare("SELECT person_id FROM interview_person WHERE interview_id = ?").all(i.id).map((r) => person(db, r.person_id)).filter(Boolean);
|
|
39
|
+
return { interview_id: i.id, application_id: i.application_id, round_label: i.round_label, format: i.format, status: i.status, scheduled_at: i.scheduled_at, tz: i.tz,
|
|
40
|
+
completed_at: i.completed_at, invite_received_at: i.invite_received_at, outcome: i.outcome, supersedes_id: i.supersedes_id,
|
|
41
|
+
debrief_text: i.debrief_text, debrief_captured_at: i.debrief_captured_at, pinned_ids: JSON.parse(i.pinned_ids_json || "[]"), people };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function applicationDetail(db, application_id) {
|
|
45
|
+
const a = db.prepare(`SELECT a.*, p.candidate_id, p.company, p.title, p.location, p.comp_text, p.source_url, p.taken_down_at, p.status AS posting_status,
|
|
46
|
+
s.fit_score, s.verdict, s.verdict_reason, s.categories_json, s.rubric_version FROM application a JOIN posting p ON p.id = a.posting_id LEFT JOIN scoring s ON s.id = a.scoring_id WHERE a.id = ?`).get(application_id);
|
|
47
|
+
if (!a) return null;
|
|
48
|
+
const interviews = db.prepare("SELECT * FROM interview WHERE application_id = ? AND status <> 'superseded' ORDER BY scheduled_at").all(a.id).map((i) => interviewRow(db, i));
|
|
49
|
+
const people = db.prepare("SELECT person_id, role FROM application_person WHERE application_id = ?").all(a.id).map((r) => ({ ...person(db, r.person_id), role: r.role })).filter((p) => p.person_id);
|
|
50
|
+
const comms = db.prepare(`SELECT c.*, i.round_label FROM communication c LEFT JOIN interview i ON i.id = c.interview_id WHERE c.application_id = ? ORDER BY c.sent_at DESC`).all(a.id)
|
|
51
|
+
.map((c) => ({ communication_id: c.id, kind: c.kind, interview_id: c.interview_id, round_label: c.round_label, document_id: c.document_id, to_addr: c.to_addr, sent_at: c.sent_at, changed_body: !!c.sent_body_text }));
|
|
52
|
+
return {
|
|
53
|
+
application_id: a.id, posting_id: a.posting_id, status: a.status, applied_at: a.applied_at, decision_expected_at: a.decision_expected_at, notes: a.notes,
|
|
54
|
+
resume_doc_id: a.resume_doc_id, cover_doc_id: a.cover_doc_id,
|
|
55
|
+
posting: { posting_id: a.posting_id, company: a.company, title: a.title, location: a.location, comp_text: a.comp_text, source_url: a.source_url, taken_down_at: a.taken_down_at, status: a.posting_status },
|
|
56
|
+
scoring: a.scoring_id ? { scoring_id: a.scoring_id, fit_score: a.fit_score, verdict: a.verdict, verdict_reason: a.verdict_reason, rubric_version: a.rubric_version, categories: JSON.parse(a.categories_json || "[]") } : null,
|
|
57
|
+
documents: documentsFor(db, { posting_id: a.posting_id, application_id: a.id }),
|
|
58
|
+
interviews, people, communications: comms,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function interviewDetail(db, interview_id) {
|
|
63
|
+
const i = db.prepare(`SELECT i.*, p.candidate_id, p.company, p.title, p.id AS posting_id, a.status AS application_status FROM interview i
|
|
64
|
+
JOIN application a ON a.id = i.application_id JOIN posting p ON p.id = a.posting_id WHERE i.id = ?`).get(interview_id);
|
|
65
|
+
if (!i) return null;
|
|
66
|
+
const row = interviewRow(db, i);
|
|
67
|
+
const prior = db.prepare("SELECT * FROM interview WHERE application_id = ? AND id <> ? AND status <> 'superseded' AND scheduled_at < ? ORDER BY scheduled_at").all(i.application_id, i.id, i.scheduled_at)
|
|
68
|
+
.map((x) => interviewRow(db, x));
|
|
69
|
+
const later = db.prepare("SELECT * FROM interview WHERE application_id = ? AND id <> ? AND status <> 'superseded' AND scheduled_at >= ? ORDER BY scheduled_at").all(i.application_id, i.id, i.scheduled_at)
|
|
70
|
+
.map((x) => interviewRow(db, x));
|
|
71
|
+
const slice = row.pinned_ids.length ? loadSlice(db, i.candidate_id, row.pinned_ids) : new Map();
|
|
72
|
+
const runs = db.prepare(`SELECT r.id AS run_id, r.operation, r.route, r.final_result, r.started_at, (SELECT count(*) FROM ai_attempt t WHERE t.ai_run_id = r.id) AS attempts
|
|
73
|
+
FROM ai_run r WHERE r.interview_id = ? ORDER BY r.started_at DESC LIMIT 30`).all(i.id);
|
|
74
|
+
return {
|
|
75
|
+
...row, company: i.company, title: i.title, posting_id: i.posting_id, application_status: i.application_status,
|
|
76
|
+
prior_rounds: prior, later_rounds: later,
|
|
77
|
+
pinned: [...slice.values()].map((r) => ({ id: r.id, title: r.title, company: r.company })),
|
|
78
|
+
documents: documentsFor(db, { interview_id: i.id }),
|
|
79
|
+
communications: db.prepare("SELECT id AS communication_id, kind, sent_at, to_addr FROM communication WHERE interview_id = ? ORDER BY sent_at DESC").all(i.id),
|
|
80
|
+
runs,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function documentDetail(db, document_id) {
|
|
85
|
+
const d = db.prepare("SELECT * FROM document WHERE id = ?").get(document_id);
|
|
86
|
+
if (!d) return null;
|
|
87
|
+
const run = d.ai_attempt_id ? db.prepare("SELECT r.id AS run_id, r.operation, r.interview_id, r.posting_id FROM ai_attempt t JOIN ai_run r ON r.id = t.ai_run_id WHERE t.id = ?").get(d.ai_attempt_id) : null;
|
|
88
|
+
let header = null;
|
|
89
|
+
if (d.interview_id) {
|
|
90
|
+
const i = db.prepare("SELECT i.round_label, i.format, i.scheduled_at, i.tz, p.company, p.title FROM interview i JOIN application a ON a.id = i.application_id JOIN posting p ON p.id = a.posting_id WHERE i.id = ?").get(d.interview_id);
|
|
91
|
+
header = { ...i, people: db.prepare("SELECT person_id FROM interview_person WHERE interview_id = ?").all(d.interview_id).map((r) => person(db, r.person_id)).filter(Boolean) };
|
|
92
|
+
} else if (d.posting_id) header = db.prepare("SELECT company, title FROM posting WHERE id = ?").get(d.posting_id);
|
|
93
|
+
else if (d.application_id) header = db.prepare("SELECT p.company, p.title FROM application a JOIN posting p ON p.id = a.posting_id WHERE a.id = ?").get(d.application_id);
|
|
94
|
+
return { ...docRow(d), content: JSON.parse(d.content_json), provenance: JSON.parse(d.provenance_json || "{}"), run, header, candidate_id: d.candidate_id };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function searchAccomplishments(db, candidate_id, q, limit = 20) {
|
|
98
|
+
const records = [...loadSlice(db, candidate_id).values()];
|
|
99
|
+
let chosen = records;
|
|
100
|
+
if (q && q.trim()) {
|
|
101
|
+
const { rankEvidence } = require("../core/scoring");
|
|
102
|
+
const { toScoringShape } = require("../core/generators/serialize");
|
|
103
|
+
const ranked = rankEvidence(q, records.map(toScoringShape), limit);
|
|
104
|
+
const byId = new Map(records.map((r) => [r.id, r]));
|
|
105
|
+
chosen = ranked.map((e) => byId.get(e.accomplishmentId));
|
|
106
|
+
}
|
|
107
|
+
return chosen.slice(0, limit).map((r) => serializeRecord(r));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { listApplications, applicationDetail, interviewDetail, documentDetail, documentsFor, searchAccomplishments, person };
|
package/store/render.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Markdown export of a passed document: a readable file on disk, the
|
|
5
|
+
* store remembering where it went (rendered_rel_path). Round, people, and
|
|
6
|
+
* datetime come from the store at render time; the model never wrote them.
|
|
7
|
+
* Claims are dropped from the rendering; provenance stays on the row.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const { SCHEMAS } = require("../core/schemas");
|
|
13
|
+
const { withTx } = require("./tx");
|
|
14
|
+
const { appendEvent } = require("./events");
|
|
15
|
+
|
|
16
|
+
const SCHEMA_FOR_KIND = { resume: "resume", cover: "cover", prep: "prep", onepager: "onepager", deep_answers: "deep_answers", practice: "practice", email: "followup" };
|
|
17
|
+
|
|
18
|
+
const label = (k) => k.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^\w/, (c) => c.toUpperCase());
|
|
19
|
+
|
|
20
|
+
function renderNode(value, node, depth, out) {
|
|
21
|
+
if (node.claimed) { if (value && value.text) out.push(value.text, ""); return; }
|
|
22
|
+
if (node.t === "object") {
|
|
23
|
+
for (const [k, sub] of Object.entries(node.fields)) {
|
|
24
|
+
if (!(k in value)) continue;
|
|
25
|
+
const v = value[k];
|
|
26
|
+
if (sub.t === "string" || sub.claimed || sub.t === "number" || sub.t === "boolean") {
|
|
27
|
+
if (v === "" || v == null) continue;
|
|
28
|
+
out.push(`**${label(k)}:** ${sub.claimed ? v.text : v}`, "");
|
|
29
|
+
} else {
|
|
30
|
+
out.push(`${"#".repeat(Math.min(6, depth))} ${label(k)}`, "");
|
|
31
|
+
renderNode(v, sub, depth + 1, out);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} else if (node.t === "array") {
|
|
35
|
+
const items = node.items;
|
|
36
|
+
if (items.t === "string") for (const v of value) out.push(`- ${v}`);
|
|
37
|
+
else if (items.claimed) for (const v of value) out.push(`- ${v.text}`);
|
|
38
|
+
else value.forEach((v, i) => { out.push(`${i + 1}.`); const inner = []; renderNode(v, items, depth + 1, inner); out.push(...inner.map((l) => (l ? " " + l : l))); });
|
|
39
|
+
out.push("");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderResume(c, out) {
|
|
44
|
+
out.push(`# ${c.headline}`, "", (c.specialties || []).join(" · "), "");
|
|
45
|
+
if (c.headerLinks && c.headerLinks.length) out.push(c.headerLinks.join(" | "), "");
|
|
46
|
+
out.push("## Career Highlights", "");
|
|
47
|
+
for (const h of c.careerHighlights || []) out.push(`- **${h.leadIn}** ${h.proof && h.proof.text || ""}`);
|
|
48
|
+
out.push("", `## ${c.experienceSectionTitle || "Experience"}`, "");
|
|
49
|
+
for (const e of c.experience || []) {
|
|
50
|
+
out.push(`### ${e.role}, ${e.company} (${e.dates})${e.section === "concurrent" ? " · concurrent" : ""}`, "");
|
|
51
|
+
if (e.scope) out.push(`*${e.scope}*`, "");
|
|
52
|
+
for (const b of e.bullets || []) out.push(`- ${b.text && b.text.text || ""}`);
|
|
53
|
+
out.push("");
|
|
54
|
+
}
|
|
55
|
+
if (c.skills && c.skills.length) out.push("## Skills", "", c.skills.join(" · "), "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function header(db, doc) {
|
|
59
|
+
const lines = [];
|
|
60
|
+
if (doc.interview_id) {
|
|
61
|
+
const iv = db.prepare(`SELECT i.round_label, i.format, i.scheduled_at, i.tz, p.company, p.title FROM interview i
|
|
62
|
+
JOIN application a ON a.id = i.application_id JOIN posting p ON p.id = a.posting_id WHERE i.id = ?`).get(doc.interview_id);
|
|
63
|
+
const people = db.prepare("SELECT pe.name, pe.title FROM interview_person ip JOIN person pe ON pe.id = ip.person_id WHERE ip.interview_id = ?").all(doc.interview_id);
|
|
64
|
+
lines.push(`${iv.company}: ${iv.title}`, `${iv.round_label} ${iv.format}, ${iv.scheduled_at} ${iv.tz}`);
|
|
65
|
+
if (people.length) lines.push("With " + people.map((p) => p.title ? `${p.name} (${p.title})` : p.name).join(", "));
|
|
66
|
+
} else if (doc.posting_id) {
|
|
67
|
+
const p = db.prepare("SELECT company, title FROM posting WHERE id = ?").get(doc.posting_id);
|
|
68
|
+
lines.push(`${p.company}: ${p.title}`);
|
|
69
|
+
} else if (doc.application_id) {
|
|
70
|
+
const p = db.prepare("SELECT p.company, p.title FROM application a JOIN posting p ON p.id = a.posting_id WHERE a.id = ?").get(doc.application_id);
|
|
71
|
+
lines.push(`${p.company}: ${p.title}`);
|
|
72
|
+
}
|
|
73
|
+
return lines;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderMarkdown(db, doc) {
|
|
77
|
+
const content = JSON.parse(doc.content_json);
|
|
78
|
+
const out = [];
|
|
79
|
+
const head = header(db, doc);
|
|
80
|
+
if (doc.kind === "resume") renderResume(content, out);
|
|
81
|
+
else if (doc.kind === "email") out.push(`**Subject:** ${content.subject}`, "", content.body && content.body.text || "", "");
|
|
82
|
+
else {
|
|
83
|
+
out.push(`# ${label(doc.kind)}${head.length ? ": " + head[0] : ""}`, "");
|
|
84
|
+
for (const h of head.slice(1)) out.push(h);
|
|
85
|
+
if (head.length > 1) out.push("");
|
|
86
|
+
renderNode(content, SCHEMAS[SCHEMA_FOR_KIND[doc.kind]], 2, out);
|
|
87
|
+
}
|
|
88
|
+
return out.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function slug(s) { return String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40); }
|
|
92
|
+
|
|
93
|
+
/** Render a passed document to <outDir>; record the path relative to homeDir on the row. */
|
|
94
|
+
function renderDocument(db, { document_id, outDir, homeDir, now, actor }) {
|
|
95
|
+
const doc = db.prepare("SELECT * FROM document WHERE id = ?").get(document_id);
|
|
96
|
+
if (!doc) throw new Error("unknown document");
|
|
97
|
+
if (doc.status !== "passed") throw new Error("only a passed document renders; this one is blocked");
|
|
98
|
+
const head = header(db, doc);
|
|
99
|
+
const md = renderMarkdown(db, doc);
|
|
100
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
101
|
+
const file = path.join(outDir, `${doc.kind}-${slug(head[0] || "document")}-${doc.id.slice(-6).toLowerCase()}.md`);
|
|
102
|
+
fs.writeFileSync(file, md, "utf8");
|
|
103
|
+
const rel = homeDir ? path.relative(homeDir, file) : file;
|
|
104
|
+
withTx(db, (d) => {
|
|
105
|
+
d.prepare("UPDATE document SET rendered_rel_path = ? WHERE id = ?").run(rel, doc.id);
|
|
106
|
+
appendEvent(d, { candidate_id: doc.candidate_id, entity: "document", entity_id: doc.id, event: "rendered", actor_type: actor.type, actor_ref: actor.ref, payload: { path: rel }, at: now });
|
|
107
|
+
});
|
|
108
|
+
return { file, rel, bytes: Buffer.byteLength(md) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { renderMarkdown, renderDocument };
|
package/store/reply.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One entry for "the AI replied to run X": the run's operation picks the
|
|
5
|
+
* door. Documents go through ingest_document; an extraction through
|
|
6
|
+
* propose_from_source; a discovery through the question door. Nothing
|
|
7
|
+
* here decides on content, only on the run row.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { ingestDocument } = require("./ingest");
|
|
11
|
+
|
|
12
|
+
function saveReply(db, a) {
|
|
13
|
+
const run = a.run_id ? db.prepare("SELECT operation FROM ai_run WHERE id = ?").get(a.run_id) : null;
|
|
14
|
+
if (run && run.operation === "extract") return require("./onboarding").saveExtraction(db, a);
|
|
15
|
+
if (run && run.operation === "discover") return require("./discover").saveDiscovery(db, a);
|
|
16
|
+
return ingestDocument(db, a);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { saveReply };
|
package/store/review.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The review queue: every dimension still in review state, and the three
|
|
5
|
+
* human actions on it: confirm, edit, drop. Each action bumps kb_revision
|
|
6
|
+
* and logs an event with the actor. This is the only path that promotes a
|
|
7
|
+
* dimension to confirmed; no model route reaches it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { withTx } = require("./tx");
|
|
11
|
+
const { appendEvent } = require("./events");
|
|
12
|
+
|
|
13
|
+
const DIMS = { metric: "metrics_json", verb: "verbs_json", claim: "verbatim_claims_json" };
|
|
14
|
+
|
|
15
|
+
function keyOf(dim, item) {
|
|
16
|
+
return dim === "metric" ? item.value : dim === "verb" ? item.verb : item.text;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function listReview(db, candidate_id, { record } = {}) {
|
|
20
|
+
const rows = record
|
|
21
|
+
? [db.prepare("SELECT id, title, company, metrics_json, verbs_json, verbatim_claims_json FROM accomplishment WHERE candidate_id = ? AND id = ?").get(candidate_id, record)].filter(Boolean)
|
|
22
|
+
: db.prepare("SELECT id, title, company, metrics_json, verbs_json, verbatim_claims_json FROM accomplishment WHERE candidate_id = ? ORDER BY id").all(candidate_id);
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const r of rows) {
|
|
25
|
+
for (const [dim, col] of Object.entries(DIMS)) {
|
|
26
|
+
for (const item of JSON.parse(r[col])) {
|
|
27
|
+
if (item.review_state === "confirmed") continue;
|
|
28
|
+
out.push({ id: r.id, title: r.title, company: r.company, dimension: dim, value: keyOf(dim, item),
|
|
29
|
+
detail: dim === "metric" ? item.outcome_key : dim === "claim" ? item.type : `tier ${item.tier}` });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function mutate(db, { candidate_id, record, dimension, value, action, edits = {}, now, actor }) {
|
|
37
|
+
const col = DIMS[dimension];
|
|
38
|
+
if (!col) throw new Error(`dimension must be one of ${Object.keys(DIMS).join(", ")}`);
|
|
39
|
+
return withTx(db, (d) => {
|
|
40
|
+
const row = d.prepare(`SELECT ${col} AS items FROM accomplishment WHERE id = ? AND candidate_id = ?`).get(record, candidate_id);
|
|
41
|
+
if (!row) throw new Error(`unknown record ${record}`);
|
|
42
|
+
const items = JSON.parse(row.items);
|
|
43
|
+
const idx = value === "*" ? -1 : items.findIndex((it) => keyOf(dimension, it).toLowerCase() === String(value).toLowerCase());
|
|
44
|
+
if (idx < 0 && value !== "*") throw new Error(`no ${dimension} "${value}" on ${record}`);
|
|
45
|
+
const targets = value === "*" ? items.map((_, i) => i) : [idx];
|
|
46
|
+
const prior = JSON.parse(JSON.stringify(items));
|
|
47
|
+
let changed = 0;
|
|
48
|
+
for (const i of targets.slice().reverse()) {
|
|
49
|
+
const it = items[i];
|
|
50
|
+
if (action === "confirm") { if (it.review_state !== "confirmed") { it.review_state = "confirmed"; changed++; } }
|
|
51
|
+
else if (action === "drop") { items.splice(i, 1); changed++; }
|
|
52
|
+
else if (action === "edit") { Object.assign(it, edits, { review_state: "confirmed" }); changed++; }
|
|
53
|
+
else throw new Error("action must be confirm, edit, or drop");
|
|
54
|
+
}
|
|
55
|
+
if (!changed) return { changed: 0 };
|
|
56
|
+
d.prepare(`UPDATE accomplishment SET ${col} = ? WHERE id = ?`).run(JSON.stringify(items), record);
|
|
57
|
+
d.prepare("UPDATE candidate SET kb_revision = kb_revision + 1 WHERE id = ?").run(candidate_id);
|
|
58
|
+
appendEvent(d, { candidate_id, entity: "accomplishment", entity_id: record, event: `review_${action}`, actor_type: actor.type, actor_ref: actor.ref,
|
|
59
|
+
payload: { dimension, value, edits }, prior: { [col]: prior }, at: now });
|
|
60
|
+
return { changed, remaining: items.filter((it) => it.review_state !== "confirmed").length };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { listReview, mutate, DIMS };
|
package/store/scoring.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Scoring contract (plan): categories are proposed, you edit, you freeze.
|
|
5
|
+
* Every jd_evidence line must be a verbatim substring of raw_text or the
|
|
6
|
+
* proposal is rejected. Freeze ranks evidence per category with scoring.js
|
|
7
|
+
* unchanged, computes the weighted mean on the 10 scale, records verdict
|
|
8
|
+
* and verdict_reason, hashes the evidence inputs, and supersedes the prior
|
|
9
|
+
* row. Packets and applications use frozen rows only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const crypto = require("crypto");
|
|
13
|
+
const { ulid } = require("../core/ulid");
|
|
14
|
+
const { scanJobDescription, rankEvidence, evaluateGate } = require("../core/scoring");
|
|
15
|
+
const { SCHEMAS, validate } = require("../core/schemas");
|
|
16
|
+
const { rulesVersion } = require("../core/rules");
|
|
17
|
+
const { toScoringShape } = require("../core/generators/serialize");
|
|
18
|
+
const { loadSlice } = require("./context");
|
|
19
|
+
const { withTx } = require("./tx");
|
|
20
|
+
const { appendEvent } = require("./events");
|
|
21
|
+
|
|
22
|
+
const ENGINE_VERSION = "scan-1";
|
|
23
|
+
const STRENGTH_SCORE = { Strong: 8, Moderate: 6, Weak: 4 };
|
|
24
|
+
const DEFAULT_PURSUE_MIN = 6;
|
|
25
|
+
|
|
26
|
+
function sha(s) { return crypto.createHash("sha256").update(s).digest("hex"); }
|
|
27
|
+
|
|
28
|
+
function postingOf(db, posting_id) {
|
|
29
|
+
const p = db.prepare("SELECT * FROM posting WHERE id = ?").get(posting_id);
|
|
30
|
+
if (!p) throw new Error("unknown posting");
|
|
31
|
+
return p;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Reject any category whose evidence is not verbatim in the posting; return schema errors too. */
|
|
35
|
+
function checkCategories(categories, raw_text) {
|
|
36
|
+
const errors = validate({ categories }, SCHEMAS.categories).map((e) => `${e.path}: ${e.message}`);
|
|
37
|
+
categories.forEach((c, i) => {
|
|
38
|
+
for (const [j, e] of (c.jd_evidence || []).entries()) {
|
|
39
|
+
if (!raw_text.includes(e)) errors.push(`$.categories[${i}].jd_evidence[${j}]: not a verbatim substring of the posting`);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
return errors;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Deterministic proposal from the scan: hard gates first, then minimum, responsibilities, preferred. */
|
|
46
|
+
function proposeFromScan(db, { posting_id, now, actor }) {
|
|
47
|
+
const p = postingOf(db, posting_id);
|
|
48
|
+
const scan = scanJobDescription(p.raw_text);
|
|
49
|
+
const cats = [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
const META = /^(?:\*\*)?(?:source|status|company|title|location|url|link|posted|applied|captured|comp|compensation|salary|pay|date)\b/i;
|
|
52
|
+
const HEADING = /^(?:(?:minimum|basic|required|preferred|key|core|essential)\s+)?(?:requirements?|qualifications?|preferred|nice to haves?|bonus|responsibilities|duties|what you.?ll do|the role|about (?:us|the role|you|the team)|benefits|compensation)\b[^a-z]*$/i;
|
|
53
|
+
const title = String(p.title || "").toLowerCase();
|
|
54
|
+
const add = (line, weight, hard) => {
|
|
55
|
+
const l = String(line).trim();
|
|
56
|
+
const name = l.replace(/^[-*•\u2022\d.)\s]+/, "").replace(/[^\w&/ ,'()+%$-]+/g, " ").replace(/\s+/g, " ").trim();
|
|
57
|
+
if (!l || l.length < 12 || seen.has(l) || cats.length >= 8 || HEADING.test(name) || name.split(/\s+/).length < 3) return;
|
|
58
|
+
if (title && name.toLowerCase().includes(title)) return; // the posting's own title line is not a requirement
|
|
59
|
+
seen.add(l);
|
|
60
|
+
cats.push({ category: name.slice(0, 80), weight, jd_evidence: [l.slice(0, 400)], is_hard_gate: hard });
|
|
61
|
+
};
|
|
62
|
+
for (const l of scan.hardGateLines) add(l, 10, true);
|
|
63
|
+
for (const l of scan.sectionText.minimum || []) add(l, 9, false);
|
|
64
|
+
for (const l of scan.sectionText.responsibilities || []) add(l, 7, false);
|
|
65
|
+
for (const l of scan.sectionText.preferred || []) add(l, 5, false);
|
|
66
|
+
for (const l of scan.sectionText.body || []) if (cats.length < 5 && !META.test(String(l).trim())) add(l, 6, false);
|
|
67
|
+
// A short posting still needs five rows to edit: pad from the remaining lines, shortest requirement last, at a low weight.
|
|
68
|
+
if (cats.length < 5) {
|
|
69
|
+
const all = [...(scan.sectionText.minimum || []), ...(scan.sectionText.responsibilities || []), ...(scan.sectionText.preferred || []), ...(scan.sectionText.body || [])];
|
|
70
|
+
for (const l of all) {
|
|
71
|
+
if (cats.length >= 5) break;
|
|
72
|
+
const t = String(l).trim();
|
|
73
|
+
const name = t.replace(/^[-*•\u2022\d.)\s]+/, "").replace(/\s+/g, " ").trim();
|
|
74
|
+
if (t.length < 8 || seen.has(t) || HEADING.test(name) || META.test(t)) continue;
|
|
75
|
+
seen.add(t);
|
|
76
|
+
cats.push({ category: name.slice(0, 80), weight: 5, jd_evidence: [t.slice(0, 400)], is_hard_gate: false });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const errors = checkCategories(cats, p.raw_text);
|
|
80
|
+
if (errors.length) throw new Error("scan produced too few usable categories; propose them by hand or through a model: " + errors.join("; "));
|
|
81
|
+
return withTx(db, (d) => {
|
|
82
|
+
const id = ulid(Date.parse(now));
|
|
83
|
+
const kb = d.prepare("SELECT kb_revision FROM candidate WHERE id = ?").get(p.candidate_id).kb_revision;
|
|
84
|
+
d.prepare(`INSERT INTO scoring (id, posting_id, categories_json, status, engine_version, rubric_version, kb_revision, scored_at)
|
|
85
|
+
VALUES (?, ?, ?, 'proposed', ?, ?, ?, ?)`).run(id, p.id, JSON.stringify(cats), ENGINE_VERSION, rulesVersion(), kb, now);
|
|
86
|
+
appendEvent(d, { candidate_id: p.candidate_id, entity: "scoring", entity_id: id, event: "proposed", actor_type: actor.type, actor_ref: actor.ref,
|
|
87
|
+
payload: { posting_id: p.id, categories: cats.length, from: "scan" }, at: now });
|
|
88
|
+
return { scoring_id: id, categories: cats };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Freeze. categories: the edited list (or the latest proposal when omitted). scores: optional
|
|
94
|
+
* [{category, score}] from the human; otherwise the mechanical score from evidence strength.
|
|
95
|
+
*/
|
|
96
|
+
function freezeScoring(db, { posting_id, categories, scores, now, actor, thresholds = {} }) {
|
|
97
|
+
const p = postingOf(db, posting_id);
|
|
98
|
+
const prior = db.prepare("SELECT * FROM scoring WHERE posting_id = ? ORDER BY scored_at DESC, rowid DESC LIMIT 1").get(p.id);
|
|
99
|
+
// Omitted categories means re-freeze the latest row's categories (edited scores, a new kb revision).
|
|
100
|
+
const bare = (c) => ({ category: c.category, weight: c.weight, jd_evidence: c.jd_evidence, is_hard_gate: !!c.is_hard_gate });
|
|
101
|
+
const cats = (categories || (prior ? JSON.parse(prior.categories_json) : [])).map(bare);
|
|
102
|
+
if (!cats.length) throw new Error("no categories to freeze; run score-freeze --from-scan or pass --categories");
|
|
103
|
+
const errors = checkCategories(cats, p.raw_text);
|
|
104
|
+
if (errors.length) throw new Error("categories rejected: " + errors.join("; "));
|
|
105
|
+
const records = [...loadSlice(db, p.candidate_id).values()];
|
|
106
|
+
const KB = records.map(toScoringShape);
|
|
107
|
+
const byName = new Map((scores || []).map((s) => [String(s.category), Number(s.score)]));
|
|
108
|
+
const frozen = cats.map((c) => {
|
|
109
|
+
const ev = rankEvidence([c.category, ...(c.jd_evidence || [])].join(" "), KB, 5);
|
|
110
|
+
const mechanical = ev.length ? STRENGTH_SCORE[ev[0].evidence_strength] : 2;
|
|
111
|
+
const score = byName.has(c.category) && isFinite(byName.get(c.category)) ? byName.get(c.category) : mechanical;
|
|
112
|
+
return { ...c, score, evidence_ids: ev.map((e) => e.accomplishmentId), evidence_strength: ev.length ? ev[0].evidence_strength : "None" };
|
|
113
|
+
});
|
|
114
|
+
const gate = evaluateGate([], frozen.map((c) => ({ category: c.category, weight: c.weight, score: c.score })));
|
|
115
|
+
const pursueMin = thresholds.pursue_min ?? DEFAULT_PURSUE_MIN;
|
|
116
|
+
const verdict = gate.gate === "BLOCKED" || gate.overall == null || gate.overall < pursueMin ? "pass" : "pursue";
|
|
117
|
+
const reason = gate.blocking.length
|
|
118
|
+
? "hard gate: " + gate.blocking.map((b) => `${b.category} (weight ${b.weight}, score ${b.score})`).join("; ")
|
|
119
|
+
: `weighted mean ${gate.overall} against a ${pursueMin} floor`;
|
|
120
|
+
return withTx(db, (d) => {
|
|
121
|
+
const id = ulid(Date.parse(now));
|
|
122
|
+
const kb = d.prepare("SELECT kb_revision FROM candidate WHERE id = ?").get(p.candidate_id).kb_revision;
|
|
123
|
+
const evidenceHash = sha(JSON.stringify(frozen.map((c) => [c.category, c.weight, c.jd_evidence, c.score, c.evidence_ids])) + "|kb" + kb);
|
|
124
|
+
d.prepare(`INSERT INTO scoring (id, posting_id, categories_json, status, fit_score, verdict, verdict_reason, engine_version, rubric_version, kb_revision, scoring_evidence_hash, supersedes_id, scored_at)
|
|
125
|
+
VALUES (?, ?, ?, 'frozen', ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
126
|
+
.run(id, p.id, JSON.stringify(frozen), gate.overall ?? 0, verdict, reason, ENGINE_VERSION, rulesVersion(), kb, evidenceHash, prior ? prior.id : null, now);
|
|
127
|
+
d.prepare("UPDATE application SET scoring_id = ? WHERE posting_id = ?").run(id, p.id);
|
|
128
|
+
appendEvent(d, { candidate_id: p.candidate_id, entity: "scoring", entity_id: id, event: "frozen", actor_type: actor.type, actor_ref: actor.ref,
|
|
129
|
+
payload: { posting_id: p.id, fit_score: gate.overall, verdict, supersedes: prior ? prior.id : null }, at: now });
|
|
130
|
+
return { scoring_id: id, fit_score: gate.overall, verdict, verdict_reason: reason, categories: frozen };
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { proposeFromScan, freezeScoring, checkCategories, ENGINE_VERSION };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Settings (screen 6): the route, thresholds, export folder, house rules on the candidate row, and the
|
|
5
|
+
* provider registry. Keys are write-only: a read returns a mask and the
|
|
6
|
+
* last four characters; a write with an empty key keeps the stored one.
|
|
7
|
+
* Nothing here is ever logged with a body.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const crypto = require("crypto");
|
|
11
|
+
const { readConfig, writeConfig } = require("./config");
|
|
12
|
+
const { DEFAULT_STYLE, REGISTERS, houseFor } = require("../core/house_rules");
|
|
13
|
+
const { withTx } = require("./tx");
|
|
14
|
+
const { appendEvent } = require("./events");
|
|
15
|
+
|
|
16
|
+
const ROUTES = ["paste", "mcp", "byo_key", "credits"];
|
|
17
|
+
const KINDS = ["anthropic", "openai_compatible", "gemini"];
|
|
18
|
+
|
|
19
|
+
function mask(key) {
|
|
20
|
+
if (!key) return null;
|
|
21
|
+
const s = String(key);
|
|
22
|
+
return "••••" + s.slice(-4);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function providerView(p) {
|
|
26
|
+
return { id: p.id, kind: p.kind, label: p.label, model: p.model, base_url: p.base_url || null, key: mask(p.api_key), has_key: !!p.api_key, last_test: p.last_test || null };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function creditsView(c) {
|
|
30
|
+
c = c || {};
|
|
31
|
+
return { relay_url: c.relay_url || null, token: mask(c.id_token), has_token: !!c.id_token, last_balance: c.last_balance || null };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getSettings(db, homeDir, candidate_id) {
|
|
35
|
+
const cfg = readConfig(homeDir);
|
|
36
|
+
const cand = db.prepare("SELECT style_config_json, name FROM candidate WHERE id = ?").get(candidate_id);
|
|
37
|
+
const style = JSON.parse((cand && cand.style_config_json) || "{}");
|
|
38
|
+
return {
|
|
39
|
+
route: cfg.route || "paste", active_provider_id: cfg.active_provider_id || null, providers: (cfg.providers || []).map(providerView), credits: creditsView(cfg.credits),
|
|
40
|
+
thresholds: cfg.thresholds, export_dir: cfg.export_dir || null, backup_dir: cfg.backup_dir || null, last_backup: cfg.last_backup || null, port: cfg.port || null, home: homeDir, candidate: { id: candidate_id, name: cand && cand.name },
|
|
41
|
+
house_rules: { ...houseFor(style), defaults: DEFAULT_STYLE },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function putSettings(db, homeDir, candidate_id, patch, { now, actor }) {
|
|
46
|
+
const cfg = readConfig(homeDir);
|
|
47
|
+
const changed = [];
|
|
48
|
+
if (patch.route !== undefined) { if (!ROUTES.includes(patch.route)) throw Object.assign(new Error(`route must be one of ${ROUTES.join(", ")}`), { status: 400 }); cfg.route = patch.route; changed.push("route"); }
|
|
49
|
+
if (patch.active_provider_id !== undefined) { cfg.active_provider_id = patch.active_provider_id || null; changed.push("active_provider_id"); }
|
|
50
|
+
if (patch.export_dir !== undefined) { cfg.export_dir = patch.export_dir ? String(patch.export_dir) : undefined; changed.push("export_dir"); }
|
|
51
|
+
if (patch.backup_dir !== undefined) {
|
|
52
|
+
const dir = patch.backup_dir ? String(patch.backup_dir).trim() : "";
|
|
53
|
+
if (dir && require("path").resolve(dir) === require("path").resolve(homeDir)) throw Object.assign(new Error("the backup folder cannot be the data folder itself; pick a folder a sync client may watch, the live store never goes there"), { status: 400 });
|
|
54
|
+
cfg.backup_dir = dir || undefined;
|
|
55
|
+
changed.push("backup_dir");
|
|
56
|
+
}
|
|
57
|
+
if (patch.credits && typeof patch.credits === "object") {
|
|
58
|
+
const c = cfg.credits || {};
|
|
59
|
+
if (patch.credits.relay_url !== undefined) {
|
|
60
|
+
const u = String(patch.credits.relay_url || "").trim().replace(/\/+$/, "");
|
|
61
|
+
if (u && !/^https:\/\//i.test(u) && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?/i.test(u)) throw Object.assign(new Error("relay url must be https, or http on loopback"), { status: 400 });
|
|
62
|
+
c.relay_url = u || null;
|
|
63
|
+
}
|
|
64
|
+
if (patch.credits.id_token) c.id_token = String(patch.credits.id_token).trim();
|
|
65
|
+
if (patch.credits.id_token === null) delete c.id_token;
|
|
66
|
+
cfg.credits = c;
|
|
67
|
+
changed.push("credits");
|
|
68
|
+
}
|
|
69
|
+
if (patch.thresholds && typeof patch.thresholds === "object") {
|
|
70
|
+
for (const [k, v] of Object.entries(patch.thresholds)) {
|
|
71
|
+
if (v == null || v === "") delete cfg.thresholds[k];
|
|
72
|
+
else if (isFinite(Number(v))) cfg.thresholds[k] = Number(v);
|
|
73
|
+
}
|
|
74
|
+
changed.push("thresholds");
|
|
75
|
+
}
|
|
76
|
+
writeConfig(homeDir, cfg);
|
|
77
|
+
if (patch.house_rules && typeof patch.house_rules === "object") {
|
|
78
|
+
const h = patch.house_rules;
|
|
79
|
+
const clean = {};
|
|
80
|
+
for (const rule of ["no_dashes", "style_config"]) if (h[rule] && typeof h[rule] === "object") {
|
|
81
|
+
clean[rule] = {};
|
|
82
|
+
for (const [kind, sev] of Object.entries(h[rule])) if (["block", "warn", "off"].includes(sev)) clean[rule][kind] = sev;
|
|
83
|
+
}
|
|
84
|
+
if (Array.isArray(h.hyphen_allow)) clean.hyphen_allow = h.hyphen_allow.map(String).filter(Boolean).slice(0, 50);
|
|
85
|
+
if (h.bullet_register !== undefined) { if (!REGISTERS.includes(h.bullet_register)) throw Object.assign(new Error(`bullet_register must be one of ${REGISTERS.join(", ")}`), { status: 400 }); clean.bullet_register = h.bullet_register; }
|
|
86
|
+
if (Array.isArray(h.vocabulary)) clean.vocabulary = h.vocabulary.filter((v) => v && v.pattern && v.message).map((v) => ({ pattern: String(v.pattern), flags: String(v.flags || ""), message: String(v.message) })).slice(0, 50);
|
|
87
|
+
withTx(db, (d) => {
|
|
88
|
+
const prior = d.prepare("SELECT style_config_json FROM candidate WHERE id = ?").get(candidate_id);
|
|
89
|
+
d.prepare("UPDATE candidate SET style_config_json = ? WHERE id = ?").run(JSON.stringify(clean), candidate_id);
|
|
90
|
+
appendEvent(d, { candidate_id, entity: "candidate", entity_id: candidate_id, event: "house_rules", actor_type: actor.type, actor_ref: actor.ref, payload: { keys: Object.keys(clean) }, prior: { style_config_json: prior.style_config_json }, at: now });
|
|
91
|
+
});
|
|
92
|
+
changed.push("house_rules");
|
|
93
|
+
}
|
|
94
|
+
if (changed.length) withTx(db, (d) => appendEvent(d, { candidate_id, entity: "settings", entity_id: candidate_id, event: "updated", actor_type: actor.type, actor_ref: actor.ref, payload: { changed }, at: now }));
|
|
95
|
+
return getSettings(db, homeDir, candidate_id);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Add or update a provider. Only the key of the named provider changes; an empty key keeps the old one. */
|
|
99
|
+
function putProvider(homeDir, id, body) {
|
|
100
|
+
const cfg = readConfig(homeDir);
|
|
101
|
+
cfg.providers = cfg.providers || [];
|
|
102
|
+
if (!KINDS.includes(body.kind)) throw Object.assign(new Error(`kind must be one of ${KINDS.join(", ")}`), { status: 400 });
|
|
103
|
+
const base = body.base_url ? String(body.base_url).trim() : "";
|
|
104
|
+
if (base && !/^https:\/\//i.test(base) && !/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?/i.test(base)) throw Object.assign(new Error("base_url must be https, or http on loopback"), { status: 400 });
|
|
105
|
+
let p = cfg.providers.find((x) => x.id === id);
|
|
106
|
+
if (!p) { p = { id: id || crypto.randomBytes(4).toString("hex"), api_key: "" }; cfg.providers.push(p); }
|
|
107
|
+
p.kind = body.kind;
|
|
108
|
+
p.label = String(body.label || p.label || body.kind).slice(0, 60);
|
|
109
|
+
p.model = String(body.model || p.model || "").slice(0, 120);
|
|
110
|
+
p.base_url = base || null;
|
|
111
|
+
if (body.api_key) p.api_key = String(body.api_key);
|
|
112
|
+
writeConfig(homeDir, cfg);
|
|
113
|
+
return providerView(p);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function deleteProvider(homeDir, id) {
|
|
117
|
+
const cfg = readConfig(homeDir);
|
|
118
|
+
cfg.providers = (cfg.providers || []).filter((p) => p.id !== id);
|
|
119
|
+
if (cfg.active_provider_id === id) cfg.active_provider_id = null;
|
|
120
|
+
writeConfig(homeDir, cfg);
|
|
121
|
+
return { deleted: id };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function recordBalance(homeDir, result) {
|
|
125
|
+
const cfg = readConfig(homeDir);
|
|
126
|
+
cfg.credits = { ...(cfg.credits || {}), last_balance: result };
|
|
127
|
+
writeConfig(homeDir, cfg);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function recordBackup(homeDir, result) {
|
|
131
|
+
const cfg = readConfig(homeDir);
|
|
132
|
+
cfg.last_backup = result;
|
|
133
|
+
writeConfig(homeDir, cfg);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function recordTest(homeDir, id, result) {
|
|
137
|
+
const cfg = readConfig(homeDir);
|
|
138
|
+
const p = (cfg.providers || []).find((x) => x.id === id);
|
|
139
|
+
if (p) { p.last_test = result; writeConfig(homeDir, cfg); }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { getSettings, putSettings, putProvider, deleteProvider, recordTest, recordBalance, recordBackup, mask, ROUTES, KINDS };
|