@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/mcp/tools.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The tiered tool set (plan, section on tiers). Read tools, one door
|
|
5
|
+
* (save_document), proposed-row tools through ingest_proposal. Nothing here
|
|
6
|
+
* confirms, schedules, sends, applies, or changes a setting; those are
|
|
7
|
+
* human clicks in the UI. Every write carries actor mcp.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const { z } = require("zod");
|
|
12
|
+
const { snapshot } = require("../store/snapshot");
|
|
13
|
+
const { openRun } = require("../store/packets");
|
|
14
|
+
const { ingestDocument, MAX_ATTEMPTS } = require("../store/ingest");
|
|
15
|
+
const { ingestProposal } = require("../store/proposals");
|
|
16
|
+
const { loadSlice } = require("../store/context");
|
|
17
|
+
const { latestFrozenScoring } = require("../store/packets");
|
|
18
|
+
const { serializeRecord, toScoringShape } = require("../core/generators/serialize");
|
|
19
|
+
const { rankEvidence } = require("../core/scoring");
|
|
20
|
+
const { PacketError } = require("../core/generators/packet");
|
|
21
|
+
const { SliceError } = require("../core/generators/slice");
|
|
22
|
+
|
|
23
|
+
const PACKET_OPS = ["resume", "cover", "prep", "onepager", "deep_answers", "followup", "practice"];
|
|
24
|
+
const INTERVIEW_OPS = new Set(["prep", "onepager", "deep_answers", "followup", "practice"]);
|
|
25
|
+
|
|
26
|
+
const text = (o) => ({ content: [{ type: "text", text: typeof o === "string" ? o : JSON.stringify(o, null, 1) }] });
|
|
27
|
+
const fail = (msg, extra) => ({ content: [{ type: "text", text: JSON.stringify({ error: msg, ...(extra || {}) }, null, 1) }], isError: true });
|
|
28
|
+
|
|
29
|
+
function guarded(fn) {
|
|
30
|
+
return async (args, ctx) => {
|
|
31
|
+
try { return await fn(args || {}, ctx); } catch (e) {
|
|
32
|
+
if (e instanceof PacketError || e instanceof SliceError) return fail(e.message, e.detail ? { detail: e.detail } : undefined);
|
|
33
|
+
return fail(e.message);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const CATEGORY = z.object({ category: z.string(), weight: z.number(), jd_evidence: z.array(z.string()), is_hard_gate: z.boolean().optional() });
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {object} deps { db, candidate_id, thresholds, exportDir, now: () => iso, actor: (ref) => {type, ref} }
|
|
42
|
+
*/
|
|
43
|
+
function registerTools(server, deps) {
|
|
44
|
+
const { db, candidate_id } = deps;
|
|
45
|
+
const now = deps.now || (() => new Date().toISOString());
|
|
46
|
+
const actor = (ref) => ({ type: "mcp", ref });
|
|
47
|
+
|
|
48
|
+
server.registerTool("campaign_snapshot", {
|
|
49
|
+
title: "Campaign snapshot",
|
|
50
|
+
description: "The dashboard: what needs the candidate now, upcoming interviews with ids, active and closed applications, aging applications. Read only.",
|
|
51
|
+
inputSchema: z.object({}),
|
|
52
|
+
}, guarded(() => text(snapshot(db, { candidate_id, now: now(), thresholds: deps.thresholds }))));
|
|
53
|
+
|
|
54
|
+
server.registerTool("list_interviews", {
|
|
55
|
+
title: "List interviews",
|
|
56
|
+
description: "Interviews with ids, round, format, status, time and zone, company, and people. Optional ISO bounds. Read only.",
|
|
57
|
+
inputSchema: z.object({ from: z.string().optional(), to: z.string().optional(), include_completed: z.boolean().optional() }),
|
|
58
|
+
}, guarded(({ from, to, include_completed }) => {
|
|
59
|
+
const rows = db.prepare(`SELECT i.id AS interview_id, i.application_id, i.round_label, i.format, i.status, i.scheduled_at, i.tz, i.outcome, i.invite_received_at IS NOT NULL AS invite_received,
|
|
60
|
+
p.company, p.title, p.id AS posting_id FROM interview i JOIN application a ON a.id = i.application_id JOIN posting p ON p.id = a.posting_id
|
|
61
|
+
WHERE p.candidate_id = ? AND i.status <> 'superseded' AND (? IS NULL OR i.scheduled_at >= ?) AND (? IS NULL OR i.scheduled_at <= ?) AND (? OR i.status = 'scheduled') ORDER BY i.scheduled_at`)
|
|
62
|
+
.all(candidate_id, from || null, from || null, to || null, to || null, include_completed ? 1 : 0);
|
|
63
|
+
const people = db.prepare("SELECT ip.interview_id, pe.id, pe.name, pe.title FROM interview_person ip JOIN person pe ON pe.id = ip.person_id").all();
|
|
64
|
+
return text(rows.map((r) => ({ ...r, invite_received: !!r.invite_received, people: people.filter((p) => p.interview_id === r.interview_id).map(({ id, name, title }) => ({ person_id: id, name, title })) })));
|
|
65
|
+
}));
|
|
66
|
+
|
|
67
|
+
server.registerTool("list_postings", {
|
|
68
|
+
title: "List postings",
|
|
69
|
+
description: "Postings with ids, status (proposed, accepted, archived), whether scoring is frozen, and the application status if any. Read only.",
|
|
70
|
+
inputSchema: z.object({ status: z.enum(["proposed", "accepted", "archived"]).optional() }),
|
|
71
|
+
}, guarded(({ status }) => text(db.prepare(`SELECT p.id AS posting_id, p.status, p.company, p.title, p.location, p.comp_text, p.captured_at,
|
|
72
|
+
(SELECT count(*) FROM scoring s WHERE s.posting_id = p.id AND s.status = 'frozen') > 0 AS scoring_frozen, a.id AS application_id, a.status AS application_status
|
|
73
|
+
FROM posting p LEFT JOIN application a ON a.posting_id = p.id WHERE p.candidate_id = ? AND (? IS NULL OR p.status = ?) ORDER BY p.captured_at DESC`)
|
|
74
|
+
.all(candidate_id, status || null, status || null).map((r) => ({ ...r, scoring_frozen: !!r.scoring_frozen })))));
|
|
75
|
+
|
|
76
|
+
server.registerTool("get_posting", {
|
|
77
|
+
title: "Get posting",
|
|
78
|
+
description: "One posting: status, company, title, location, comp, source, and the text (bounded to 12000 characters). Read only.",
|
|
79
|
+
inputSchema: z.object({ posting_id: z.string() }),
|
|
80
|
+
}, guarded(({ posting_id }) => {
|
|
81
|
+
const p = db.prepare("SELECT id AS posting_id, status, company, title, location, comp_text, source_url, captured_at, taken_down_at, length(raw_text) AS raw_length, substr(raw_text, 1, 12000) AS text FROM posting WHERE id = ? AND candidate_id = ?").get(posting_id, candidate_id);
|
|
82
|
+
return p ? text(p) : fail("unknown posting");
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
server.registerTool("get_scoring", {
|
|
86
|
+
title: "Get scoring",
|
|
87
|
+
description: "The frozen scoring for a posting (categories, weights, evidence, fit, verdict) and the latest proposal if one is waiting. Read only.",
|
|
88
|
+
inputSchema: z.object({ posting_id: z.string() }),
|
|
89
|
+
}, guarded(({ posting_id }) => {
|
|
90
|
+
const p = db.prepare("SELECT id FROM posting WHERE id = ? AND candidate_id = ?").get(posting_id, candidate_id);
|
|
91
|
+
if (!p) return fail("unknown posting");
|
|
92
|
+
const frozen = latestFrozenScoring(db, posting_id);
|
|
93
|
+
const proposed = db.prepare("SELECT id, categories_json, scored_at FROM scoring WHERE posting_id = ? AND status = 'proposed' ORDER BY scored_at DESC, rowid DESC LIMIT 1").get(posting_id);
|
|
94
|
+
const strip = (s) => s && { scoring_id: s.id, status: s.status, fit_score: s.fit_score, verdict: s.verdict, verdict_reason: s.verdict_reason, categories: s.categories, scored_at: s.scored_at };
|
|
95
|
+
return text({ frozen: frozen && frozen.status === "frozen" ? strip(frozen) : null, proposed: proposed ? { scoring_id: proposed.id, categories: JSON.parse(proposed.categories_json), scored_at: proposed.scored_at } : null });
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
server.registerTool("list_accomplishments", {
|
|
99
|
+
title: "List accomplishments",
|
|
100
|
+
description: "Knowledge base records with ids and confirmed dimensions only. With a query, the records ranked by evidence for it. Read only.",
|
|
101
|
+
inputSchema: z.object({ query: z.string().optional(), limit: z.number().int().min(1).max(100).optional() }),
|
|
102
|
+
}, guarded(({ query, limit }) => {
|
|
103
|
+
const records = [...loadSlice(db, candidate_id).values()];
|
|
104
|
+
let chosen = records;
|
|
105
|
+
if (query) {
|
|
106
|
+
const ranked = rankEvidence(query, records.map(toScoringShape), limit || 10);
|
|
107
|
+
const byId = new Map(records.map((r) => [r.id, r]));
|
|
108
|
+
chosen = ranked.map((e) => byId.get(e.accomplishmentId));
|
|
109
|
+
} else if (limit) chosen = records.slice(0, limit);
|
|
110
|
+
return text(chosen.map((r) => serializeRecord(r)));
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
server.registerTool("get_person", {
|
|
114
|
+
title: "Get person",
|
|
115
|
+
description: "A person by id or by name match, resolved through merges, with the applications and interviews they sit on. Read only.",
|
|
116
|
+
inputSchema: z.object({ person_id: z.string().optional(), name: z.string().optional() }),
|
|
117
|
+
}, guarded(({ person_id, name }) => {
|
|
118
|
+
if (!person_id && !name) return fail("person_id or name is required");
|
|
119
|
+
let rows = person_id
|
|
120
|
+
? db.prepare("SELECT * FROM person WHERE id = ? AND candidate_id = ?").all(person_id, candidate_id)
|
|
121
|
+
: db.prepare("SELECT * FROM person WHERE candidate_id = ? AND lower(name) LIKE lower(?)").all(candidate_id, "%" + name + "%");
|
|
122
|
+
rows = rows.map((r) => (r.merged_into_id ? db.prepare("SELECT * FROM person WHERE id = ?").get(r.merged_into_id) : r));
|
|
123
|
+
return text(rows.map((r) => ({
|
|
124
|
+
person_id: r.id, name: r.name, title: r.title, org: r.org, intel: r.intel_text, links: JSON.parse(r.links_json || "[]"),
|
|
125
|
+
applications: db.prepare("SELECT ap.application_id, ap.role, p.company FROM application_person ap JOIN application a ON a.id = ap.application_id JOIN posting p ON p.id = a.posting_id WHERE ap.person_id = ?").all(r.id),
|
|
126
|
+
interviews: db.prepare("SELECT ip.interview_id, i.round_label, i.scheduled_at FROM interview_person ip JOIN interview i ON i.id = ip.interview_id WHERE ip.person_id = ?").all(r.id),
|
|
127
|
+
})));
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
for (const op of PACKET_OPS) {
|
|
131
|
+
const byInterview = INTERVIEW_OPS.has(op);
|
|
132
|
+
server.registerTool(`get_${op}_packet`, {
|
|
133
|
+
title: `${op} packet`,
|
|
134
|
+
description: `Build the ${op} packet: rules, context, the knowledge base slice with ids, and the output schema. Opens a run and returns run_id; write the document and call save_document with that run_id. ${byInterview ? "Needs an interview_id." : "Needs an accepted posting_id."} Refuses without frozen scoring where scoring applies.`,
|
|
135
|
+
inputSchema: byInterview ? z.object({ interview_id: z.string() }) : z.object({ posting_id: z.string() }),
|
|
136
|
+
}, guarded((args) => {
|
|
137
|
+
const r = openRun(db, { op, interview_id: args.interview_id, posting_id: args.posting_id, route: "mcp", actor: actor(`get_${op}_packet`), now: now() });
|
|
138
|
+
return text({ run_id: r.run_id, attempts_allowed: MAX_ATTEMPTS, instruction: `Produce JSON matching output_schema, then call save_document with run_id ${r.run_id} and the JSON as content_json. Cite kb_slice ids in claims.`, packet: r.packet });
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
server.registerTool("save_document", {
|
|
143
|
+
title: "Save document",
|
|
144
|
+
description: "The one door onto the document table. Runs the schema, provenance, and the failure ledger on content_json for the run the packet opened. Blocked returns isError with every block (field path, span, hint, nearest records). Three attempts per run, then stop.",
|
|
145
|
+
inputSchema: z.object({ run_id: z.string(), content_json: z.union([z.string(), z.record(z.string(), z.unknown())]) }),
|
|
146
|
+
}, guarded(({ run_id, content_json }) => {
|
|
147
|
+
const r = ingestDocument(db, { run_id, content: content_json, route: "mcp", actor: actor("save_document"), now: now() });
|
|
148
|
+
const body = { run_id: r.run_id, status: r.status, attempt: r.attempt, document_id: r.document_id || null, stop: !!r.stop, blocks: r.blocks, warnings: r.warnings };
|
|
149
|
+
return r.status === "passed" ? text(body) : { ...text(body), isError: true };
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
server.registerTool("store_posting", {
|
|
153
|
+
title: "Store posting",
|
|
154
|
+
description: "Store a posting as proposed (the candidate accepts it in the UI). Idempotent on company, title, and the text hash. Never accepted from here.",
|
|
155
|
+
inputSchema: z.object({ company: z.string(), title: z.string(), raw_text: z.string(), source_url: z.string().optional(), comp_text: z.string().optional(), location: z.string().optional() }),
|
|
156
|
+
}, guarded((a) => text(ingestProposal(db, "posting", { candidate_id, ...a, now: now(), actor: actor("store_posting") }))));
|
|
157
|
+
|
|
158
|
+
server.registerTool("propose_categories", {
|
|
159
|
+
title: "Propose scoring categories",
|
|
160
|
+
description: "Propose Part K categories for a posting: five to eight, weights 1 to 10 from the employer's emphasis, each with one to six verbatim jd_evidence lines from the posting text. Rejected whole if any line is not verbatim. The candidate edits and freezes.",
|
|
161
|
+
inputSchema: z.object({ posting_id: z.string(), categories: z.array(CATEGORY).min(5).max(8) }),
|
|
162
|
+
}, guarded((a) => text(ingestProposal(db, "categories", { candidate_id, ...a, now: now(), actor: actor("propose_categories") }))));
|
|
163
|
+
|
|
164
|
+
server.registerTool("kb_gap_request", {
|
|
165
|
+
title: "Request a knowledge base gap answer",
|
|
166
|
+
description: "Ask the candidate a question about a requirement (verbatim from the posting) the knowledge base cannot answer. Creates a gap the candidate answers later.",
|
|
167
|
+
inputSchema: z.object({ posting_id: z.string(), requirement_text: z.string(), question: z.string() }),
|
|
168
|
+
}, guarded((a) => text(ingestProposal(db, "gap", { candidate_id, ...a, now: now(), actor: actor("kb_gap_request") }))));
|
|
169
|
+
|
|
170
|
+
server.registerTool("propose_accomplishment", {
|
|
171
|
+
title: "Propose an accomplishment",
|
|
172
|
+
description: "Propose a knowledge base record from something the candidate told you. Lands pending; the candidate confirms every metric and verb before it is citable. Twenty open at a time.",
|
|
173
|
+
inputSchema: z.object({ draft: z.object({ title: z.string(), company: z.string(), role: z.string(), dates: z.string().optional(), summary: z.string().optional(),
|
|
174
|
+
metrics: z.array(z.string()).optional(), verbs: z.array(z.string()).optional(), wordings: z.array(z.string()).optional() }), gap_id: z.string().optional() }),
|
|
175
|
+
}, guarded((a) => text(ingestProposal(db, "accomplishment", { candidate_id, ...a, now: now(), actor: actor("propose_accomplishment") }))));
|
|
176
|
+
|
|
177
|
+
// onboarding tier: propose_from_source, never the document door
|
|
178
|
+
const onboarding = require("../store/onboarding");
|
|
179
|
+
server.registerTool("list_sources", {
|
|
180
|
+
title: "List source documents",
|
|
181
|
+
description: "The candidate's uploaded resumes and records for onboarding, with ids and whether text extraction succeeded. Read only.",
|
|
182
|
+
inputSchema: z.object({}),
|
|
183
|
+
}, guarded(() => text(onboarding.listSources(db, candidate_id))));
|
|
184
|
+
server.registerTool("get_extract_packet", {
|
|
185
|
+
title: "Extraction packet",
|
|
186
|
+
description: "Build the onboarding extraction packet for one source document: rules, the source text, and the output schema. Every proposed atom must be a verbatim excerpt with its character span. Returns run_id for save_extraction.",
|
|
187
|
+
inputSchema: z.object({ source_document_id: z.string() }),
|
|
188
|
+
}, guarded(({ source_document_id }) => {
|
|
189
|
+
const r = openRun(db, { op: "extract", source_document_id, route: "mcp", actor: actor("get_extract_packet"), now: now() });
|
|
190
|
+
return text({ run_id: r.run_id, attempts_allowed: MAX_ATTEMPTS, instruction: `Produce JSON matching output_schema with verbatim excerpts and spans, then call save_extraction with run_id ${r.run_id}.`, packet: r.packet });
|
|
191
|
+
}));
|
|
192
|
+
server.registerTool("save_extraction", {
|
|
193
|
+
title: "Save extraction",
|
|
194
|
+
description: "propose_from_source: the proposed accomplishments land as pending rows the candidate confirms on the knowledge base screen. Rejected whole, with isError, if any atom is not a verbatim excerpt of the source at its span. Three attempts per run.",
|
|
195
|
+
inputSchema: z.object({ run_id: z.string(), content_json: z.union([z.string(), z.record(z.string(), z.unknown())]) }),
|
|
196
|
+
}, guarded(({ run_id, content_json }) => {
|
|
197
|
+
const r = onboarding.saveExtraction(db, { run_id, content: content_json, route: "mcp", actor: actor("save_extraction"), now: now() });
|
|
198
|
+
return r.status === "passed" ? text(r) : { ...text(r), isError: true };
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
// discovery tier: questions in, pending answers out; never the document door
|
|
202
|
+
const discover = require("../store/discover");
|
|
203
|
+
server.registerTool("get_discover_packet", {
|
|
204
|
+
title: "Discovery packet",
|
|
205
|
+
description: "Build the discovery packet: the candidate's confirmed records (thinnest first), the holes the engine sees, and every prior question. Reply with questions only, each tied to a record id in the slice; the candidate answers them on the knowledge base screen. Refused while questions are still open. Returns run_id for save_discovery.",
|
|
206
|
+
inputSchema: z.object({}),
|
|
207
|
+
}, guarded(() => {
|
|
208
|
+
discover.assertCanOpen(db, candidate_id);
|
|
209
|
+
const r = openRun(db, { op: "discover", candidate_id, route: "mcp", actor: actor("get_discover_packet"), now: now() });
|
|
210
|
+
return text({ run_id: r.run_id, attempts_allowed: discover.MAX_ATTEMPTS, instruction: `Produce JSON matching output_schema (questions about records in the slice, by id), then call save_discovery with run_id ${r.run_id}.`, packet: r.packet });
|
|
211
|
+
}));
|
|
212
|
+
server.registerTool("save_discovery", {
|
|
213
|
+
title: "Save discovery questions",
|
|
214
|
+
description: "Stores the questions as open items on the knowledge base screen. Rejected whole, with isError, if the JSON misses the schema or any question names a record outside the packet's slice. Three attempts per run. Never writes to a record.",
|
|
215
|
+
inputSchema: z.object({ run_id: z.string(), content_json: z.union([z.string(), z.record(z.string(), z.unknown())]) }),
|
|
216
|
+
}, guarded(({ run_id, content_json }) => {
|
|
217
|
+
const r = discover.saveDiscovery(db, { run_id, content: content_json, route: "mcp", actor: actor("save_discovery"), now: now() });
|
|
218
|
+
return r.status === "passed" ? text(r) : { ...text(r), isError: true };
|
|
219
|
+
}));
|
|
220
|
+
server.registerTool("list_questions", {
|
|
221
|
+
title: "List open discovery questions",
|
|
222
|
+
description: "The discovery questions the candidate has not yet answered or dismissed, each with the record it is about. Read only; answers are written by the candidate.",
|
|
223
|
+
inputSchema: z.object({}),
|
|
224
|
+
}, guarded(() => text(discover.listQuestions(db, candidate_id))));
|
|
225
|
+
|
|
226
|
+
server.registerTool("export_tracker_xlsx", {
|
|
227
|
+
title: "Export the tracker",
|
|
228
|
+
description: "Write a dated tracker xlsx to the export folder and return its path.",
|
|
229
|
+
inputSchema: z.object({}),
|
|
230
|
+
}, guarded(async () => {
|
|
231
|
+
const { exportTracker } = require("../store/export_xlsx");
|
|
232
|
+
const r = await exportTracker(db, { candidate_id, outDir: deps.exportDir, now: now() });
|
|
233
|
+
return text({ file: path.resolve(r.file), rows: r.rows });
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = { registerTools, PACKET_OPS };
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gafj/gafj",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "GAF-J: a local campaign engine for job search. One SQLite file, one ingest door, your own AI subscription.",
|
|
5
|
+
"homepage": "https://gaf-j.com",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/MVHProvGrd/resume-generator.git",
|
|
9
|
+
"directory": "gafj"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"resume",
|
|
16
|
+
"job-search",
|
|
17
|
+
"interview",
|
|
18
|
+
"local-first",
|
|
19
|
+
"sqlite"
|
|
20
|
+
],
|
|
21
|
+
"license": "UNLICENSED",
|
|
22
|
+
"bin": {
|
|
23
|
+
"gafj": "bin/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"main": "store/index.js",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=24.0.0"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin",
|
|
31
|
+
"core",
|
|
32
|
+
"store",
|
|
33
|
+
"http",
|
|
34
|
+
"mcp",
|
|
35
|
+
"bridge",
|
|
36
|
+
"ui",
|
|
37
|
+
"README.md"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "node --test --no-warnings \"test/*.test.js\"",
|
|
41
|
+
"test:private": "node --test --no-warnings \"test/private/*.test.js\""
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
45
|
+
"exceljs": "4.4.0",
|
|
46
|
+
"htm": "3.1.1",
|
|
47
|
+
"mammoth": "1.12.2",
|
|
48
|
+
"preact": "10.29.8",
|
|
49
|
+
"unpdf": "1.8.1",
|
|
50
|
+
"zod": "4.5.4"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@modelcontextprotocol/client": "2.0.0",
|
|
54
|
+
"jsqr": "1.4.0"
|
|
55
|
+
},
|
|
56
|
+
"overrides": {
|
|
57
|
+
"uuid": "^11.1.1"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/store/backup.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Backups use SQLite's online backup API when the runtime exposes it, and
|
|
5
|
+
* VACUUM INTO otherwise. Never a file copy of a live WAL database. Restore
|
|
6
|
+
* goes into a fresh home by default; restoring over a live store requires an
|
|
7
|
+
* explicit flag and takes a backup first. Every restore is verified with
|
|
8
|
+
* integrity_check and a migration pass.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
const { open } = require("./db");
|
|
14
|
+
const { migrate } = require("./migrate");
|
|
15
|
+
|
|
16
|
+
async function backup(db, destFile) {
|
|
17
|
+
fs.mkdirSync(path.dirname(destFile), { recursive: true });
|
|
18
|
+
const sqlite = require("node:sqlite");
|
|
19
|
+
if (typeof sqlite.backup === "function") {
|
|
20
|
+
await sqlite.backup(db, destFile);
|
|
21
|
+
return { file: destFile, method: "online-backup" };
|
|
22
|
+
}
|
|
23
|
+
db.exec(`VACUUM INTO '${destFile.replace(/'/g, "''")}'`);
|
|
24
|
+
return { file: destFile, method: "vacuum-into" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function backupName(now) {
|
|
28
|
+
return `gafj-${String(now).replace(/[-:]/g, "").slice(0, 13).replace("T", "-")}.db`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function verify(file) {
|
|
32
|
+
const db = open(file);
|
|
33
|
+
try {
|
|
34
|
+
const check = db.prepare("PRAGMA integrity_check").get();
|
|
35
|
+
if (check.integrity_check !== "ok") throw new Error(`integrity_check: ${check.integrity_check}`);
|
|
36
|
+
const version = migrate(db);
|
|
37
|
+
return { version };
|
|
38
|
+
} finally {
|
|
39
|
+
db.close();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} backupFile
|
|
45
|
+
* @param {string} homeDir target home; must not already hold a store unless intoLive
|
|
46
|
+
*/
|
|
47
|
+
async function restore(backupFile, homeDir, { intoLive = false, now } = {}) {
|
|
48
|
+
const target = path.join(homeDir, "gafj.db");
|
|
49
|
+
fs.mkdirSync(homeDir, { recursive: true });
|
|
50
|
+
if (fs.existsSync(target)) {
|
|
51
|
+
if (!intoLive) throw new Error(`${target} exists; restore into a fresh home, or pass intoLive to replace it after a backup`);
|
|
52
|
+
const live = open(target);
|
|
53
|
+
try { await backup(live, path.join(homeDir, "backups", "pre-restore-" + backupName(now || new Date().toISOString()))); }
|
|
54
|
+
finally { live.close(); }
|
|
55
|
+
for (const suffix of ["", "-wal", "-shm"]) fs.rmSync(target + suffix, { force: true });
|
|
56
|
+
}
|
|
57
|
+
verify(backupFile);
|
|
58
|
+
fs.copyFileSync(backupFile, target);
|
|
59
|
+
const v = verify(target);
|
|
60
|
+
return { file: target, version: v.version };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { backup, restore, verify, backupName };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where backups go: config.backup_dir when set (a folder a sync client may
|
|
5
|
+
* watch: Drive, OneDrive, Dropbox), else <export dir>/backups. The live
|
|
6
|
+
* store never goes to the backup folder; only consistent copies do. A
|
|
7
|
+
* daily copy is taken at serve start when the newest one is older than a
|
|
8
|
+
* day, and the settings screen records the last one.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
const { backup, backupName } = require("./backup");
|
|
14
|
+
|
|
15
|
+
const DAY_MS = 24 * 3600 * 1000;
|
|
16
|
+
const KEEP = 14;
|
|
17
|
+
|
|
18
|
+
function backupDir(cfg, exportDir) {
|
|
19
|
+
return cfg.backup_dir || path.join(exportDir, "backups");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function newest(dir) {
|
|
23
|
+
try {
|
|
24
|
+
return fs.readdirSync(dir).filter((f) => /^gafj-\d{8}-\d{4}\.db$/.test(f)).sort().pop() || null;
|
|
25
|
+
} catch (_) { return null; }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Keep the newest KEEP dated copies; older ones go. Pre-restore copies are never touched. */
|
|
29
|
+
function prune(dir, keep = KEEP) {
|
|
30
|
+
let files;
|
|
31
|
+
try { files = fs.readdirSync(dir).filter((f) => /^gafj-\d{8}-\d{4}\.db$/.test(f)).sort(); } catch (_) { return []; }
|
|
32
|
+
const gone = files.slice(0, Math.max(0, files.length - keep));
|
|
33
|
+
for (const f of gone) fs.rmSync(path.join(dir, f), { force: true });
|
|
34
|
+
return gone;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function backupTo(db, dir, now) {
|
|
38
|
+
const r = await backup(db, path.join(dir, backupName(now)));
|
|
39
|
+
const pruned = prune(dir);
|
|
40
|
+
return { ...r, dir, pruned: pruned.length };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** At serve start: a copy if the newest in the folder is older than a day. Never throws; a bad folder is reported, not fatal. */
|
|
44
|
+
async function dailyBackup(db, dir, now) {
|
|
45
|
+
const last = newest(dir);
|
|
46
|
+
const stamp = backupName(now).slice(5, 18); // YYYYMMDD-HHMM
|
|
47
|
+
if (last) {
|
|
48
|
+
const m = last.match(/^gafj-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})\.db$/);
|
|
49
|
+
const at = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]));
|
|
50
|
+
if (Date.parse(now) - at < DAY_MS) return { skipped: true, last, dir };
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const r = await backupTo(db, dir, now);
|
|
54
|
+
return { skipped: false, file: r.file, dir, stamp, pruned: r.pruned };
|
|
55
|
+
} catch (e) {
|
|
56
|
+
return { skipped: true, error: e.message, dir };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { backupDir, backupTo, dailyBackup, prune, newest, KEEP, DAY_MS };
|
package/store/config.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* config.json beside the store. Holds the active candidate, ports, tokens,
|
|
5
|
+
* providers, and thresholds. Never backed up with the store. Secrets are
|
|
6
|
+
* masked by callers that display it; this module only reads and writes.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const { DEFAULT_THRESHOLDS } = require("./snapshot");
|
|
12
|
+
|
|
13
|
+
function configPath(homeDir) {
|
|
14
|
+
return path.join(homeDir, "config.json");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readConfig(homeDir) {
|
|
18
|
+
const p = configPath(homeDir);
|
|
19
|
+
if (!fs.existsSync(p)) return { route: "paste", providers: [], thresholds: { ...DEFAULT_THRESHOLDS } };
|
|
20
|
+
const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
21
|
+
cfg.thresholds = { ...DEFAULT_THRESHOLDS, ...(cfg.thresholds || {}) };
|
|
22
|
+
return cfg;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeConfig(homeDir, cfg) {
|
|
26
|
+
const p = configPath(homeDir);
|
|
27
|
+
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
|
|
28
|
+
try { fs.chmodSync(p, 0o600); } catch (_) { /* Windows */ }
|
|
29
|
+
return p;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { readConfig, writeConfig, configPath };
|
package/store/context.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds the validator context from the store: the KB slice (all confirmed
|
|
5
|
+
* dimensions of the candidate, or the packet's kb_ids), this application's
|
|
6
|
+
* universe, the candidate's full universe, and the house rules.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { houseFor } = require("../core/house_rules");
|
|
10
|
+
|
|
11
|
+
function parseRecord(row) {
|
|
12
|
+
return {
|
|
13
|
+
id: row.id, title: row.title, company: row.company, role: row.role, summary: row.summary,
|
|
14
|
+
metrics_json: JSON.parse(row.metrics_json), verbs_json: JSON.parse(row.verbs_json),
|
|
15
|
+
verbatim_claims_json: JSON.parse(row.verbatim_claims_json), wordings_json: JSON.parse(row.wordings_json || "[]"),
|
|
16
|
+
tags_json: JSON.parse(row.tags_json || "[]"), strength_score: row.strength_score,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function loadSlice(db, candidate_id, ids) {
|
|
21
|
+
const rows = ids && ids.length
|
|
22
|
+
? ids.map((id) => db.prepare("SELECT * FROM accomplishment WHERE id = ? AND candidate_id = ?").get(id, candidate_id)).filter(Boolean)
|
|
23
|
+
: db.prepare("SELECT * FROM accomplishment WHERE candidate_id = ?").all(candidate_id);
|
|
24
|
+
return new Map(rows.map((r) => [r.id, parseRecord(r)]));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function applicationUniverse(db, application_id) {
|
|
28
|
+
if (!application_id) return { interview_ids: new Set(), document_ids: new Set(), person_ids: new Set(), debriefs: new Map(), rounds: [], formats: [], people: [], document_kinds: [], company: null };
|
|
29
|
+
const app = db.prepare("SELECT a.id, p.company FROM application a JOIN posting p ON p.id = a.posting_id WHERE a.id = ?").get(application_id);
|
|
30
|
+
const interviews = db.prepare("SELECT id, round_label, format, debrief_text FROM interview WHERE application_id = ?").all(application_id);
|
|
31
|
+
const docs = db.prepare(`SELECT d.id, d.kind FROM document d LEFT JOIN interview i ON i.id = d.interview_id
|
|
32
|
+
WHERE d.application_id = ? OR i.application_id = ? OR d.posting_id = (SELECT posting_id FROM application WHERE id = ?)`).all(application_id, application_id, application_id);
|
|
33
|
+
const people = db.prepare(`SELECT DISTINCT pe.id, pe.name FROM person pe
|
|
34
|
+
LEFT JOIN application_person ap ON ap.person_id = pe.id
|
|
35
|
+
LEFT JOIN interview_person ip ON ip.person_id = pe.id LEFT JOIN interview i ON i.id = ip.interview_id
|
|
36
|
+
WHERE ap.application_id = ? OR i.application_id = ?`).all(application_id, application_id);
|
|
37
|
+
return {
|
|
38
|
+
interview_ids: new Set(interviews.map((i) => i.id)),
|
|
39
|
+
document_ids: new Set(docs.map((d) => d.id)),
|
|
40
|
+
person_ids: new Set(people.map((p) => p.id)),
|
|
41
|
+
debriefs: new Map(interviews.filter((i) => i.debrief_text).map((i) => [i.id, i.debrief_text])),
|
|
42
|
+
rounds: interviews.map((i) => i.round_label),
|
|
43
|
+
formats: interviews.map((i) => i.format),
|
|
44
|
+
people: people.map((p) => p.name),
|
|
45
|
+
document_kinds: docs.map((d) => d.kind),
|
|
46
|
+
company: app ? app.company : null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function candidateUniverse(db, candidate_id, thisCompany) {
|
|
51
|
+
const companies = db.prepare("SELECT DISTINCT p.company FROM posting p JOIN application a ON a.posting_id = p.id WHERE p.candidate_id = ?").all(candidate_id).map((r) => r.company);
|
|
52
|
+
const kbCompanies = db.prepare("SELECT DISTINCT company FROM accomplishment WHERE candidate_id = ?").all(candidate_id).map((r) => r.company);
|
|
53
|
+
const cand = db.prepare("SELECT company_aliases_json FROM candidate WHERE id = ?").get(candidate_id);
|
|
54
|
+
const aliases = cand ? JSON.parse(cand.company_aliases_json || "{}") : {};
|
|
55
|
+
return {
|
|
56
|
+
other_companies: companies.filter((c) => c !== thisCompany),
|
|
57
|
+
known_entities: [...kbCompanies, ...companies],
|
|
58
|
+
aliases,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildContext(db, { candidate_id, kind, mode, application_id, kb_ids }) {
|
|
63
|
+
const cand = db.prepare("SELECT style_config_json FROM candidate WHERE id = ?").get(candidate_id);
|
|
64
|
+
const application = applicationUniverse(db, application_id);
|
|
65
|
+
return {
|
|
66
|
+
kind, mode,
|
|
67
|
+
slice: loadSlice(db, candidate_id, kb_ids),
|
|
68
|
+
application,
|
|
69
|
+
universe: candidateUniverse(db, candidate_id, application.company),
|
|
70
|
+
house: houseFor(cand ? JSON.parse(cand.style_config_json || "{}") : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { buildContext, loadSlice, applicationUniverse, candidateUniverse, parseRecord };
|
package/store/db.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The only file that knows which SQLite driver is in use. Everything else
|
|
5
|
+
* receives the handle this returns and calls exec / prepare / close on it.
|
|
6
|
+
*
|
|
7
|
+
* Open order matters on a cold start with two processes (plan section O):
|
|
8
|
+
* the constructor timeout first, then busy_timeout, then WAL, then foreign
|
|
9
|
+
* keys, then synchronous. Setting the PRAGMA after journal_mode, or setting
|
|
10
|
+
* only one of the two timeouts, is the documented way to get SQLITE_BUSY on
|
|
11
|
+
* the second process's first statement.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const BUSY_TIMEOUT_MS = 5000;
|
|
15
|
+
|
|
16
|
+
function openNodeSqlite(file) {
|
|
17
|
+
const { DatabaseSync } = require("node:sqlite");
|
|
18
|
+
const db = new DatabaseSync(file, { timeout: BUSY_TIMEOUT_MS });
|
|
19
|
+
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
20
|
+
if (file !== ":memory:") db.exec("PRAGMA journal_mode = WAL");
|
|
21
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
22
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
23
|
+
return db;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} file absolute path, or ":memory:" for tests
|
|
28
|
+
* @param {{driver?: "node:sqlite"}} [opts]
|
|
29
|
+
*/
|
|
30
|
+
function open(file, opts = {}) {
|
|
31
|
+
const driver = opts.driver || "node:sqlite";
|
|
32
|
+
if (driver === "node:sqlite") return openNodeSqlite(file);
|
|
33
|
+
// The seam: a better-sqlite3 adapter drops in here without touching callers.
|
|
34
|
+
throw new Error(`unknown sqlite driver: ${driver}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { open, BUSY_TIMEOUT_MS };
|