-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
2534 lines (2424 loc) · 158 KB
/
Copy pathserver.js
File metadata and controls
2534 lines (2424 loc) · 158 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Pythia: general-purpose conversational reporting for FileMaker databases.
// Ask in plain English; answers come from a local DuckDB copy of the connected
// database (synced over OData), with the AI choosing queries and the server
// injecting the real rows, not model-written numbers. See README.md and RULES.md.
import "./env.js";
import express from "express";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { fmConfigured, fmConnection, setConnection, withConnection, checkAuth, listDatabases, fetchSchema, fetchCounts, layoutStats, baseName, listDatabasesDataApi, fetchSchemaDataApi } from "./fm.js";
import { sql as cubeSql, syncTables, reconcileCube, cubeManifest, cubeExists, resetCube, dropTables, recommendMemoryMB, machineMemory, cancelWrites, resetCancelWrites } from "./cube.js";
import { KNOWLEDGE, kbLookup } from "./knowledge.js";
import { parseSaxml, mergeHints, hintIndex, matchTable } from "./saxml.js";
import { renderReport, renderReportParts } from "./report.js";
import { SEED_REPORTS } from "./reports.seed.js";
// AI config: env vars are the DEFAULTS; the Settings > AI panel overrides any
// of them per-install (stored on the volume in config.json). Getters read the
// config first, fall back to env – so a blank key field keeps the env key.
// key – ANTHROPIC_API_KEY / config.aiKey
// model – claude-fable-5 | -sonnet-5 | -haiku-... | -opus-4-8
// speed – "fast": fast mode (Opus-only, ~2.5x tok/s, premium, separate
// quota; needs the beta flag + top-level speed param).
// thinking – "off" (thinking:disabled, straight-to-output; matters on Sonnet),
// "deep" (extended thinking with a budget), or "" (adaptive default).
// On any failure with a knob applied, retry once with a plain request.
const ENV_KEY = process.env.ANTHROPIC_API_KEY || "";
const ENV_MODEL = process.env.ANTHROPIC_MODEL || "claude-opus-5";
const ENV_SPEED = process.env.ANTHROPIC_SPEED || "";
const ENV_THINKING = process.env.ANTHROPIC_THINKING || "";
// When the picked model is an OpenAI one, the OpenAI key is the key that
// matters; every "is a key set" gate goes through here.
const aiKey = () => {
const c = readConfig();
const m = String(c.aiModel || "");
if (/^(gpt-|o[0-9])/i.test(m)) return c.openaiKey || process.env.OPENAI_API_KEY || "";
return c.aiKey || ENV_KEY;
};
// Model families, low to high power. The effective model TRACKS the newest of
// its family (when Anthropic ships Opus 6, an un-pinned Opus 5 config rides
// along automatically); picking the one-previous model in Settings pins it.
const modelFamily = (id = "") => /fable|mythos/.test(id) ? "fable" : /opus/.test(id) ? "opus" : /sonnet/.test(id) ? "sonnet" : /haiku/.test(id) ? "haiku" : "other";
function newestOfFamily(fam) {
const c = loadModelsCache();
if (!c?.models?.length) return null;
const list = c.models.filter((m) => modelFamily(m.id) === fam)
.sort((a, b) => String(b.created_at || "").localeCompare(String(a.created_at || "")));
return list[0]?.id || null;
}
const aiModel = () => {
const c = readConfig();
const picked = c.aiModel || ENV_MODEL;
if (c.aiModelPinned) return picked;
return newestOfFamily(modelFamily(picked)) || picked;
};
// Fast is the default; anthropicFetch only applies it on Opus-family models.
// Fast mode is gone for good (Matt, 2026-08-22). It is premium-priced, it only
// ever applied to Opus, and it was one more switch nobody wanted. Never on.
const aiSpeed = () => "";
const aiThinking = () => { const c = readConfig(); return c.aiThinking !== undefined ? c.aiThinking : ENV_THINKING; };
// Snapshot taken ONCE at the start of a task, so a mid-task Settings change can't
// switch model/params mid-loop. Pass it through every call in a multi-call flow.
const aiSnapshot = () => ({ key: aiKey(), model: aiModel(), speed: aiSpeed(), thinking: aiThinking() });
// --- Chat Completions adapter ------------------------------------------------
// One translation layer so the rest of the app only ever speaks the Anthropic
// shape. When the picked model belongs to a Chat Completions provider (OpenAI's
// gpt-*), the request is translated to Chat
// Completions and the reply is translated back into
// {content:[{type:"text"|"tool_use"}], stop_reason}. The tool loop, the chart
// tools, and every call site stay untouched.
const isOpenAIModel = (m) => /^(gpt-|o[0-9])/i.test(String(m || ""));
const openaiKey = () => { const c = readConfig(); return c.openaiKey || process.env.OPENAI_API_KEY || ""; };
// opts carries the only things the providers disagree about: where to post, how
// to authorize, which knobs that server accepts, and how long to wait.
async function chatCompletionsFetch(body, timeoutMs, opts) {
const sys = typeof body.system === "string" ? body.system
: Array.isArray(body.system) ? body.system.map((b) => b.text || "").join("\n") : "";
const msgs = [];
if (sys) msgs.push({ role: "system", content: sys });
for (const m of body.messages || []) {
if (typeof m.content === "string") { msgs.push({ role: m.role, content: m.content }); continue; }
const blocks = Array.isArray(m.content) ? m.content : [];
if (m.role === "assistant") {
const text = blocks.filter((b) => b.type === "text").map((b) => b.text).join("\n");
const calls = blocks.filter((b) => b.type === "tool_use").map((b) => ({
id: b.id, type: "function", function: { name: b.name, arguments: JSON.stringify(b.input || {}) },
}));
const am = { role: "assistant", content: text || null };
if (calls.length) am.tool_calls = calls;
msgs.push(am);
} else {
for (const b of blocks) {
if (b.type === "tool_result") msgs.push({ role: "tool", tool_call_id: b.tool_use_id, content: typeof b.content === "string" ? b.content : JSON.stringify(b.content) });
else if (b.type === "text") msgs.push({ role: "user", content: b.text });
}
}
}
const tools = (body.tools || []).map((t) => ({
type: "function", function: { name: t.name, description: t.description || "", parameters: t.input_schema || { type: "object" } },
}));
const base = { model: opts.model || body.model, max_tokens: body.max_tokens || 2000, messages: msgs, ...(tools.length ? { tools } : {}) };
if (process.env.PYTHIA_DEBUG_LLM) {
console.log("[llm]", opts.label, "msgs=", msgs.map((m) => `${m.role}:${String(m.content || "").length}`).join(","), "tools=", tools.length, "| last:", JSON.stringify(String(msgs[msgs.length - 1]?.content || "").slice(0, 160)));
}
const req = opts.tune ? opts.tune(base, tools.length > 0) : base;
const res = await fetch(opts.url, {
method: "POST",
headers: { "content-type": "application/json", ...(opts.headers || {}) },
body: JSON.stringify(req), signal: AbortSignal.timeout(opts.timeoutMs || timeoutMs),
});
if (!res.ok) throw new Error(providerError(opts.label, res.status, await res.text()));
const j = await res.json();
const ch = j.choices?.[0] || {};
const content = [];
// A thinking model can put its whole answer in `reasoning` and leave content
// empty. Returning nothing there turns a correct answer into "I couldn't turn
// that into a report". Only when there is no tool call to make instead, so
// thinking text is never mistaken for a final answer mid-loop.
const finalText = ch.message?.content
|| ((ch.message?.tool_calls || []).length ? "" : ch.message?.reasoning)
|| "";
if (finalText) content.push({ type: "text", text: finalText });
if (process.env.PYTHIA_DEBUG_LLM) {
console.log("[llm-resp]", opts.label, "finish=", ch.finish_reason, "content=", String(ch.message?.content || "").length,
"reasoning=", String(ch.message?.reasoning || "").length, "calls=", (ch.message?.tool_calls || []).length,
"out_tokens=", j.usage?.completion_tokens);
for (const tc of ch.message?.tool_calls || []) console.log("[llm-call]", tc.function?.name, String(tc.function?.arguments || "").slice(0, 500));
}
(ch.message?.tool_calls || []).forEach((tc, i) => {
let input = {}; try { input = JSON.parse(tc.function?.arguments || "{}"); } catch { /* leave empty */ }
// Some Chat Completions servers omit the call id that the tool loop matches
// tool_result against. A synthetic one is fine: unique inside this reply.
content.push({ type: "tool_use", id: tc.id || `call_${i}`, name: tc.function?.name, input });
});
return {
content,
stop_reason: ch.finish_reason === "tool_calls" ? "tool_use" : ch.finish_reason === "length" ? "max_tokens" : "end_turn",
usage: { input_tokens: j.usage?.prompt_tokens || 0, output_tokens: j.usage?.completion_tokens || 0 },
};
}
function openaiFetch(body, timeoutMs) {
return chatCompletionsFetch(body, timeoutMs, {
label: "OpenAI",
url: "https://api.openai.com/v1/chat/completions",
headers: { authorization: `Bearer ${openaiKey()}` },
// GPT-5.x on /v1/chat/completions refuses function tools while reasoning is
// on; "none" is the documented way to combine them (the /v1/responses API is
// the alternative, and a bigger rewrite than this adapter wants to be).
// It also wants max_completion_tokens, not the older max_tokens.
tune: ({ max_tokens, ...r }, hasTools) => ({ ...r, max_completion_tokens: max_tokens, ...(hasTools ? { reasoning_effort: "none" } : {}) }),
});
}
async function anthropicFetch(body, timeoutMs, ai) {
if (isOpenAIModel(body.model)) return openaiFetch(body, timeoutMs);
const a = ai || aiSnapshot();
const fast = a.speed === "fast" && modelFamily(a.model) === "opus"; // fast exists only on Opus
const think = a.thinking; // "off" | "deep" | ""
const plainHeaders = { "x-api-key": a.key, "anthropic-version": "2023-06-01", "content-type": "application/json" };
// Current models (Fable/Mythos, Opus 4.7+, Sonnet 5) reject budget_tokens;
// deep = adaptive thinking + higher effort there. Fable also rejects an
// explicit disabled - omit instead. Older models keep the legacy shapes.
// The !res.ok retry below still strips all of this if a model disagrees.
const fable = /fable|mythos/.test(a.model);
const modern = fable || !/opus-4-[0-6]\b|sonnet-4|haiku|claude-3/.test(a.model);
const thinkBody = think === "off" ? (modern ? { output_config: { effort: "low" } } : { thinking: { type: "disabled" } })
: think === "deep" ? (modern
? { ...(fable ? {} : { thinking: { type: "adaptive" } }), output_config: { effort: "xhigh" }, max_tokens: Math.max(body.max_tokens || 2000, 8000) }
: { thinking: { type: "enabled", budget_tokens: 6000 }, max_tokens: Math.max(body.max_tokens || 2000, 8000) })
: {};
// Prompt caching: the system prompt (schema + notes + context) is byte-stable
// between calls and re-sent 3-4x per question by the tool loop. Marking it
// ephemeral lets Anthropic serve it from cache: ~10x cheaper prefix and a
// visibly faster first token on every call after the first. Below the
// model's cacheable minimum it silently no-ops, so this is always safe.
if (typeof body.system === "string" && body.system.length > 3000) {
body = { ...body, system: [{ type: "text", text: body.system, cache_control: { type: "ephemeral" } }] };
}
const post = (headers, b) => fetch("https://api.anthropic.com/v1/messages", {
method: "POST", headers, body: JSON.stringify(b), signal: AbortSignal.timeout(timeoutMs),
});
let res = await post(
{ ...plainHeaders, ...(fast ? { "anthropic-beta": "fast-mode-2026-02-01" } : {}) },
{ ...body, ...(fast ? { speed: "fast" } : {}), ...thinkBody }
);
if (!res.ok && (fast || think)) res = await post(plainHeaders, body);
if (!res.ok) throw new Error(providerError("Anthropic", res.status, await res.text()));
return res.json();
}
// ONE place where a provider's error becomes something a person can read.
// Every call site used to translate its own, which is why fixing the FileMaker
// 501 didn't fix the Anthropic 401: same bug, different catch block. The raw
// body goes to the console for us, never to the screen.
export function providerError(who, status, body = "") {
const raw = String(body).slice(0, 400);
console.error(`[${who} ${status}]`, raw);
let code = "", msg = "";
try { const j = JSON.parse(raw); code = j?.error?.type || ""; msg = j?.error?.message || ""; } catch { /* not JSON */ }
if (status === 401 || status === 403 || /authentication/i.test(code))
return `${who} rejected the API key. Check it in Settings.`;
if (status === 429 || /rate_limit/i.test(code))
return `${who} is rate-limiting this key right now. Wait a moment and try again.`;
if (/prompt is too long|context.*exceed/i.test(msg) || /too.*large/i.test(code))
return "That question pulled in more data than fits in one request. Ask for a narrower slice — a shorter date range, fewer tables, or one section at a time.";
if (status === 400) return `${who} refused the request${msg ? ": " + msg.slice(0, 140) : "."}`;
if (status >= 500 || status === 529) return `${who} is having trouble right now. Try again shortly.`;
return `${who} returned an unexpected error (${status}).`;
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT || 8080);
const SITE_PASSWORD = process.env.SITE_PASSWORD || "";
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, "data");
fs.mkdirSync(DATA_DIR, { recursive: true });
const CONFIG_PATH = path.join(DATA_DIR, "config.json");
const app = express();
// Password gate (Hecate's three-way pattern): if SITE_PASSWORD is set, the
// whole site requires it. Accepted three ways so it works in a browser AND a
// FileMaker web viewer (which cannot answer a Basic-auth prompt):
// 1. ?key=<password> in the URL -> also drops a cookie for later calls.
// 2. a pythia_auth cookie (set by #1).
// 3. HTTP Basic (browser prompt / curl -u).
if (SITE_PASSWORD) {
const cookieVal = `pythia_auth=${encodeURIComponent(SITE_PASSWORD)}`;
app.use((req, res, next) => {
if (req.query.key === SITE_PASSWORD) {
// Secure only on HTTPS so local http dev still works; Fly terminates TLS
// and sets x-forwarded-proto.
const secure = req.secure || req.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
res.setHeader("Set-Cookie", `${cookieVal}; Path=/; Max-Age=2592000; SameSite=Lax; HttpOnly${secure}`);
return next();
}
const cookies = req.headers.cookie || "";
if (cookies.split(";").some((c) => c.trim() === cookieVal)) return next();
const header = req.headers.authorization || "";
if (header.startsWith("Basic ")) {
const decoded = Buffer.from(header.slice(6), "base64").toString("utf8");
if (decoded.slice(decoded.indexOf(":") + 1) === SITE_PASSWORD) return next();
}
res.set("WWW-Authenticate", 'Basic realm="Pythia"');
return res.status(401).send("Authentication required. Load with ?key=<password> in a web viewer.");
});
}
// No caching: FileMaker web viewers cache aggressively.
app.use((_req, res, next) => {
res.set("Cache-Control", "no-store, no-cache, must-revalidate");
next();
});
// --- Stay-alive: one hour past the last real touch (Matt, 2026-08-24) -------
// Fly's proxy parks the machine a few MINUTES after the last inbound request
// and offers no idle-timeout knob - it killed an initial sync mid-pull. So
// Pythia keeps itself warm: any real request (or a running sync) counts as
// activity, and while the last activity is under an hour old the app pings
// its own public URL every 3 minutes. The proxy sees traffic and stays up;
// an hour after the last touch the pings stop and the machine parks as usual.
const STAY_ALIVE_MS = 60 * 60 * 1000;
let lastActivity = Date.now();
app.use((req, _res, next) => {
if (!req.headers["x-pythia-keepalive"]) lastActivity = Date.now(); // self-pings don't extend the hour
next();
});
if (process.env.FLY_APP_NAME) {
setInterval(() => {
const busy = typeof syncJob !== "undefined" && syncJob && !syncJob.done;
if (busy) lastActivity = Date.now(); // a running job is activity, always
if (Date.now() - lastActivity > STAY_ALIVE_MS) return; // past the hour: let Fly park it
fetch(`https://${process.env.FLY_APP_NAME}.fly.dev/api/health`, {
headers: { "x-pythia-keepalive": "1" }, signal: AbortSignal.timeout(10000),
}).catch(() => { /* proxy hiccup: the next tick tries again */ });
}, 3 * 60 * 1000).unref();
}
app.use(express.json({ limit: "5mb" })); // OnWindowTransaction payloads can be chunky
app.use(express.static(path.join(__dirname, "public")));
app.get("/api/health", (_req, res) => {
res.json({ ok: true, app: "pythia", stage: "config", fm: fmConfigured() });
});
// --- Relevance ranking ------------------------------------------------------
// "Which tables actually matter for reporting?" An AI pass reads each table's
// name, size, field names + developer comments, and how many layouts reference
// it, then tiers them core | reference | system and pre-picks the ones worth
// syncing. Cached to the volume, keyed by the OData schema version, so it costs
// one model call per schema change. Heuristic fallback when no API key.
const RELEVANCE_PATH = path.join(DATA_DIR, "relevance.json");
async function callAnthropic(system, user, maxTokens = 2000, ai) {
const a = ai || aiSnapshot();
// A 38-table ranking reply can exceed 60s to generate; 60s here silently
// degraded the whole pass to heuristic (no display names, no homing).
const json = await anthropicFetch(
{ model: a.model, max_tokens: maxTokens, system, messages: [{ role: "user", content: user }] },
180000, a
);
return json.content.map((c) => c.text || "").join("");
}
function heuristicRelevance(tables, stats) {
const SYSTEM = /log|dashboard|chart|field\s*def|field\s*choice|example|subtype|image|selector|self\s|address book/i;
const REFERENCE = /category|note|review|phone|employee|join|type/i;
return tables.map((t) => {
const layouts = stats?.counts?.[t.name] ?? 0;
const rows = t.rowCount; // null = count failed, NOT an empty table
let tier = "reference";
if (SYSTEM.test(t.name) || (rows != null && rows <= 2) || (layouts === 0 && t.fields.length < 6)) tier = "system";
else if (layouts >= 3 || rows >= 500) tier = "core";
else if (REFERENCE.test(t.name)) tier = "reference";
return { name: t.name, tier, include: tier !== "system", reason: `${layouts} layouts, ${rows == null ? "?" : rows.toLocaleString()} rows (heuristic)` };
});
}
async function aiRelevance(tables, stats) {
const multiFile = tables.some((t) => t.occByDb && Object.keys(t.occByDb).length > 1);
const summary = tables.map((t) => ({
name: t.name,
occurrences: (t.occurrences || []).slice(0, 8),
// OData cannot say which FILE a base table lives in (GOTCHAS.md), so give
// the model each candidate file with the TO names seen there and let it
// pick the home. Legacy multi-file solutions usually name files after
// their table, so this is strong signal.
...(multiFile ? { candidateFiles: Object.fromEntries(Object.entries(t.occByDb || {}).map(([db, occs]) => [db, occs.slice(0, 4)])) } : {}),
rows: t.rowCount ?? null,
layouts: stats?.counts?.[t.name] ?? 0,
fields: t.fields.slice(0, 14).map((f) => String(f.name).slice(0, 60)),
comments: t.fields.map((f) => f.comment).filter(Boolean).slice(0, 4).map((c) => String(c).slice(0, 120)),
}));
// HARD CEILING. This call once went out at 13.7M tokens and came back a 400,
// which silently degraded the whole pass to the heuristic - and the heuristic
// proposes NO display names, which is why tables read as C__Container and
// D_Org~B. A naming pass that cannot fit must shrink, not fail.
const MAX_CHARS = 200000;
let payload = JSON.stringify(summary, null, 1);
if (payload.length > MAX_CHARS) {
for (const t of summary) { t.fields = (t.fields || []).slice(0, 6); t.comments = []; t.occurrences = (t.occurrences || []).slice(0, 3); }
payload = JSON.stringify(summary, null, 1);
}
if (payload.length > MAX_CHARS) payload = payload.slice(0, MAX_CHARS) + "\n…(truncated)";
const system =
"You are a FileMaker data architect helping decide which base tables of a solution are worth " +
"replicating into a SQL reporting copy. Tier each table: 'core' (the central entities users " +
"report on – whatever this solution is about: e.g. transactions, orders, cases, records, people), " +
"'reference' (lookup/supporting data useful in reports: categories, notes, contact details), or 'system' (utility/UI/dev plumbing not " +
"worth reporting: logs, dashboards, chart configs, field definitions, example data, join/selector " +
"helper tables). Signals: many layouts referencing a table and higher row counts suggest importance; " +
"developer field comments reveal intent. Set include=true for core and reference, false for system. " +
"Each `name` is derived from the table's occurrence names and may be a cryptic prefixed fragment " +
"(P__Person, REP_ort, HEXP__Harvest Expense); also propose displayName, the clean human name of the " +
"underlying entity, inferred from the occurrence names, fields, and comments (e.g. \"Person\", " +
"\"Report\", \"Harvest Expense\"). Singular, title case, no prefixes. " +
"Reply ONLY with a JSON array of {name, displayName, tier, include, reason} where reason is <=12 words. " +
"Where a table has candidateFiles, ALSO set homeFile: the file the base table most likely LIVES in. " +
"Judge by file naming (a file named after the entity is its home; e.g. SubQuotes lives in 'New Quotes', " +
"not in a hub UI file like 'New Master' that references everything) and by TO naming per file. " +
"homeFile MUST be one of that table's candidateFiles keys. " +
"A row's rows:null means the count failed, not an empty table.";
const text = await callAnthropic(system, "Tables:\n" + payload, 8192);
const jsonStr = text.slice(text.indexOf("["), text.lastIndexOf("]") + 1);
return JSON.parse(jsonStr);
}
async function getRelevance(schema, stats, { light = false } = {}) {
try {
const cached = JSON.parse(fs.readFileSync(RELEVANCE_PATH, "utf8"));
// A "light" ranking (no layout stats – computed for a fast Settings open)
// satisfies light callers, but /api/schema recomputes it with full stats.
// A heuristic ranking carries no displayNames, so a scan run before the key
// was set leaves raw occurrence names (C__Container, D_Org~B) cached as
// valid. Once a key exists, that cache is stale by definition — re-rank.
// Provisional means "we could not ask the AI at the time". If we CAN ask
// now, that cache is stale by definition however recent it looks.
const staleHeuristic = (cached.provisional || String(cached.source || "").startsWith("heuristic")) && aiKey();
if (cached.version === schema.version && !(cached.basis === "light" && !light) && !staleHeuristic) return cached;
} catch { /* no cache yet */ }
let ranking, source;
if (aiKey()) {
try { ranking = await aiRelevance(schema.tables, stats); source = "ai"; }
catch (e1) {
console.error("[naming] first attempt failed:", e1?.message || e1);
{
// These failures are usually transient (timeout, overload) or size-
// related, and the second attempt costs far less than shipping a
// schema full of raw occurrence names.
try { ranking = await aiRelevance(schema.tables, stats); source = "ai"; }
catch (e2) { ranking = heuristicRelevance(schema.tables, stats); source = "heuristic (AI failed: " + String(e2.message).slice(0, 80) + ")"; }
}
}
} else {
ranking = heuristicRelevance(schema.tables, stats);
source = "heuristic (no AI key set, so table names and ranking are guessed)";
}
const out = { version: schema.version, basis: light ? "light" : "full", source, ranking, rankedAt: new Date().toISOString(),
provisional: source.startsWith("heuristic") };
try { fs.writeFileSync(RELEVANCE_PATH, JSON.stringify(out, null, 2)); } catch { /* read-only fs ok */ }
return out;
}
// Re-home merged tables to their AI-proposed home file and re-derive the real
// name from THAT file's occurrence names. OData can't tell us where a base
// table lives (GOTCHAS.md), and the most-occurrences heuristic crowns the hub
// UI file, so the ranking's homeFile – validated against the candidates we
// actually saw – is the authority. Renames flow into ranking entries (rawName
// preserved so a cached ranking still matches a fresh schema fetch) and edges.
function rehomeAndRename(schema, relevance) {
// IDENTITY IS FROZEN HERE (2026-08-18, after the afternoon that proved why).
//
// A table's NAME is its identity: it keys the saved selection, the cube, the
// watermarks, and the sync plan. This function used to RENAME tables from
// the AI ranking (re-derived names for re-homed tables, first-claimant
// suffixing in ranking order) - so every scan could mint different names,
// orphaning the user's picks. Symptoms in the field: picked tables silently
// dropped from syncs, "complete" after one table, every checkbox clearing
// itself. One flaw, four costumes.
//
// Now: rehoming still moves a table's HOME (db + query occurrence), because
// querying the right file matters. But t.name never changes after the
// schema merge. Everything the AI proposes becomes a DISPLAY name -
// presentation stacked on identity, never identity itself.
const byName = new Map(schema.tables.map((t) => [t.name, t]));
let entriesChanged = false;
for (const r of relevance.ranking || []) {
const t = byName.get(r.rawName ?? r.name) || byName.get(r.name);
if (!t) continue;
if (r.homeFile && t.occByDb?.[r.homeFile] && r.homeFile !== t.db) {
t.db = r.homeFile;
t.occurrences = t.occByDb[r.homeFile];
// The home file's own occurrence names usually make the better LABEL.
const derived = baseName(t.occurrences);
if (derived && derived !== t.name && !r.displayName) { r.displayName = derived; entriesChanged = true; }
}
// Ranking entries track the STABLE name so a cached ranking keeps matching.
if (r.name !== t.name) { if (r.rawName === undefined) r.rawName = r.name; r.name = t.name; entriesChanged = true; }
}
if (entriesChanged) { try { fs.writeFileSync(RELEVANCE_PATH, JSON.stringify(relevance, null, 2)); } catch { /* read-only fs ok */ } }
return schema;
}
// --- SaXML structural hints (ground truth OData can't provide) -------------
// Uploaded FMSaveAsXML gives, per base table: the real name, its home FILE,
// the primary key, the modification-timestamp field, and comments. OData/Data
// API expose none of these, so Pythia otherwise guesses. Hints are matched to
// live tables by field-name overlap and layered on top – additive, so an
// instance with no hints behaves exactly as before.
const HINTS_PATH = path.join(DATA_DIR, "schema-hints.json");
function loadHints() { try { return JSON.parse(fs.readFileSync(HINTS_PATH, "utf8")); } catch { return null; } }
function saveHints(h) { fs.writeFileSync(HINTS_PATH, JSON.stringify(h, null, 2)); }
// Enrich a freshly-fetched schema with hints, in place. Sets, per matched
// table: saxmlName, homeFile, comment, pk, modField, createField, hintFields;
// and re-homes the table to its true file when we hold an occurrence there.
function applyHints(schema) {
const hints = loadHints();
if (!hints || !schema?.tables?.length) return schema;
const idx = hintIndex(hints);
const dbOf = (file) => file.replace(/\.fmp12$/i, "");
let matched = 0;
for (const t of schema.tables) {
const m = matchTable((t.fields || []).map((f) => f.name), idx, { preferFile: t.db });
if (!m) continue;
matched++;
const ht = m.table;
t.saxmlName = ht.name;
t.homeFile = dbOf(m.file);
if (ht.comment) t.comment = ht.comment;
if (ht.pk && !t.pk) { t.pk = ht.pk; t.pkKind = ht.pkKind; }
if (ht.modField && !t.modField) t.modField = ht.modField;
if (ht.createField && !t.createField) t.createField = ht.createField;
t.hintFields = ht.fields;
// SaXML knows which calcs STORE their results; OData does not. Marking
// them here lets the sync keep cheap stored calcs and skip only the
// expensive unstored ones.
const storedSet = new Set((ht.fields || []).filter((f) => f.storedCalc).map((f) => f.name));
if (storedSet.size) for (const f of t.fields || []) if (storedSet.has(f.name)) f.storedCalc = true;
// Re-home to the true file only when we actually have an occurrence there
// to query; otherwise keep the queryable db but still show the true home.
if (t.occByDb && t.occByDb[t.homeFile] && t.homeFile !== t.db) {
t.db = t.homeFile;
t.occurrences = t.occByDb[t.homeFile];
}
}
schema.hintsApplied = matched;
return schema;
}
// Schema (base tables, counts, per-table layout reference counts, AI relevance
// ranking), cached in memory per boot. ?refresh=1 re-reads from the server and
// re-ranks.
let schemaCache = null;
// Every consumer of table names (sync, chat, reports) must see the SAME homed
// and renamed tables the Settings UI shows, or selections stop matching.
async function rehomedSchema(onEvent = () => {}) {
if (schemaCache) { onEvent({ type: "phase", message: "Using cached schema" }); return schemaCache; }
onEvent({ type: "phase", message: "Reading schema from FileMaker…" });
let schema;
try {
schema = await fetchSchema(false, onEvent);
} catch (e) {
// OData down – try the Data API fallback before giving up (see fm.js).
onEvent({ type: "phase", message: "OData is down – switching to Data API fallback…" });
schema = await fetchSchemaDataApi(onEvent);
}
onEvent({ type: "phase", message: "Applying table names & file homes…" });
try { rehomeAndRename(schema, await getRelevance(schema, { counts: {} }, { light: true })); } catch { /* un-homed is still usable */ }
applyHints(schema); // SaXML ground truth wins over the AI/heuristic homing
return schema;
}
app.get("/api/schema", async (req, res) => {
try {
if (!fmConfigured()) return res.json({ notConnected: true, tables: [] });
if (!schemaCache || req.query.refresh) {
const schema = await fetchSchema(Boolean(req.query.refresh));
const counts = await fetchCounts(schema.tables);
for (const t of schema.tables) t.rowCount = counts[t.name] ?? null;
let stats = { counts: {}, layoutsByTable: {}, aiLayouts: [], totalLayouts: null };
try { stats = await layoutStats(schema.tables); } catch (e) { schema.layoutStatsError = String(e.message); }
for (const t of schema.tables) {
t.layoutCount = stats.counts[t.name] ?? 0;
t.layoutNames = stats.layoutsByTable[t.name] ?? [];
}
if (req.query.refresh) { try { fs.unlinkSync(RELEVANCE_PATH); } catch {} }
const relevance = await getRelevance(schema, stats);
schema.relevanceSource = relevance.source;
rehomeAndRename(schema, relevance);
applyHints(schema); // SaXML ground truth wins over the AI/heuristic homing
tablesCache = null; // table names/homes may have changed
const byName = Object.fromEntries(relevance.ranking.map((r) => [r.name, r]));
for (const t of schema.tables) {
const r = byName[t.name] || { tier: "reference", include: true, reason: "" };
t.tier = r.tier; t.suggestInclude = r.include; t.reason = r.reason;
}
schema.counts = counts;
schema.aiLayouts = stats.aiLayouts;
schema.totalLayouts = stats.totalLayouts;
schemaCache = schema;
}
res.json(schemaCache);
} catch (e) {
res.status(502).json({ error: String(e.message || e) });
}
});
// --- Model discovery: the list comes from Anthropic, never from hard-code. --
// Cached on the volume + memory (24h); refreshed after every successful sync
// and lazily on boot. The static fallback exists only for no-key/no-network.
const MODELS_PATH = path.join(DATA_DIR, "models.json");
// Offline-only fallback. created_at matters: the UI sorts by it to pick each
// family's current + previous. Keep this list fresh when models ship — a stale
// fallback is how "Opus 5 (no longer offered)" happened.
const FALLBACK_MODELS = [
{ id: "claude-fable-5", display_name: "Claude Fable 5", created_at: "2026-07-24T00:00:00Z" },
{ id: "claude-opus-5", display_name: "Claude Opus 5", created_at: "2026-07-24T00:00:00Z" },
{ id: "claude-opus-4-8", display_name: "Claude Opus 4.8", created_at: "2026-02-05T00:00:00Z" },
{ id: "claude-sonnet-5", display_name: "Claude Sonnet 5", created_at: "2026-05-14T00:00:00Z" },
{ id: "claude-sonnet-4-6", display_name: "Claude Sonnet 4.6", created_at: "2025-11-14T00:00:00Z" },
{ id: "claude-haiku-4-5", display_name: "Claude Haiku 4.5", created_at: "2025-10-01T00:00:00Z" },
];
let modelsCache = null; // { models, fetchedAt }
function loadModelsCache() {
if (!modelsCache) { try { modelsCache = JSON.parse(fs.readFileSync(MODELS_PATH, "utf8")); } catch { /* none yet */ } }
return modelsCache;
}
let modelsFailAt = 0; // last failed refresh, for the backoff below
async function refreshModels(force = false) {
const cached = loadModelsCache();
if (!force && cached && Date.now() - new Date(cached.fetchedAt).getTime() < 86400000) return cached;
if (Date.now() - modelsFailAt < 300000) return cached; // failed recently; do not hammer
// The ANTHROPIC key specifically. aiKey() is provider-aware, so with an
// OpenAI model selected it would hand that provider's secret to
// Anthropic's endpoint. Same lesson as the Test button below.
const key = readConfig().aiKey || ENV_KEY;
if (!key) return cached;
try {
const r = await fetch("https://api.anthropic.com/v1/models?limit=100", {
headers: { "x-api-key": key, "anthropic-version": "2023-06-01" },
signal: AbortSignal.timeout(15000),
});
if (!r.ok) throw new Error(`models ${r.status}`);
const models = ((await r.json()).data || []).map((m) => ({ id: m.id, display_name: m.display_name, created_at: m.created_at }));
if (models.length) {
modelsCache = { models, fetchedAt: new Date().toISOString() };
fs.writeFileSync(MODELS_PATH, JSON.stringify(modelsCache, null, 2));
}
} catch (e) {
// Back off after a failure. A bad or absent Anthropic key fails on EVERY
// settings poll, and the repeated line buries real errors in the log.
modelsFailAt = Date.now();
console.error("model list refresh failed:", e?.message || e);
}
return modelsCache || cached;
}
// Is this key good? Answered against a key the user just typed but hasn't
// saved (same transient contract as /api/fm/test), or the stored one if the
// field was left blank. /v1/models is the cheapest possible ACK: it costs no
// tokens and still proves the key authenticates.
app.post("/api/ai/test", async (req, res) => {
// Which provider is being tested is stated by the caller, because the user
// may be testing a key BEFORE saving the model that would select it.
if (req.body?.provider === "openai") {
const okey = String(req.body?.key || "").trim() || openaiKey();
if (!okey) return res.json({ ok: false, error: "No OpenAI key to test — paste one above, or save one first." });
try {
const r = await fetch("https://api.openai.com/v1/models", {
headers: { authorization: `Bearer ${okey}` }, signal: AbortSignal.timeout(15000),
});
if (r.status === 401 || r.status === 403) return res.json({ ok: false, error: "OpenAI rejected that key." });
if (!r.ok) return res.json({ ok: false, error: `OpenAI answered ${r.status}. The key may be fine; try again shortly.` });
const ids = ((await r.json()).data || []).map((m) => m.id);
const gpt = ids.filter((i) => /^gpt-5/.test(i)).sort();
return res.json({ ok: true, model: gpt[0] || ids[0] || "", count: ids.length });
} catch (e) {
return res.json({ ok: false, error: /abort|timeout/i.test(String(e?.message || e)) ? "Timed out reaching OpenAI." : String(e?.message || e).slice(0, 160) });
}
}
// Test the ANTHROPIC key specifically: aiKey() is provider-aware and, with
// an OpenAI model selected, would hand the OpenAI key to Anthropic's
// endpoint (the cross-tab Test failure Matt hit 2026-08-23).
const c0 = readConfig();
const key = String(req.body?.key || "").trim() || c0.aiKey || ENV_KEY;
if (!key) return res.json({ ok: false, error: "No key to test — paste one above, or save one first." });
try {
const r = await fetch("https://api.anthropic.com/v1/models?limit=1", {
headers: { "x-api-key": key, "anthropic-version": "2023-06-01" },
signal: AbortSignal.timeout(15000),
});
if (r.status === 401 || r.status === 403) return res.json({ ok: false, error: "Anthropic rejected that key." });
if (!r.ok) return res.json({ ok: false, error: `Anthropic answered ${r.status}. The key may be fine; try again shortly.` });
const first = ((await r.json()).data || [])[0];
res.json({ ok: true, model: first?.display_name || first?.id || "" });
} catch (e) {
res.json({ ok: false, error: /abort|timeout/i.test(String(e?.message || e)) ? "Timed out reaching Anthropic." : String(e?.message || e).slice(0, 160) });
}
});
app.get("/api/models", async (_req, res) => {
let c = await refreshModels();
// Self-heal a stale cache: if the model we're configured to USE isn't in the
// list we're about to serve, the list is wrong, not the model — refetch now.
// Only meaningful while an Anthropic model is picked. A gpt-* model is
// never in this list, and forcing a refetch on every call is a
// guaranteed miss, not a stale cache.
const own = !isOpenAIModel(aiModel());
if (own && c?.models?.length && !c.models.some((m) => m.id === aiModel())) c = await refreshModels(true);
if (c && c.models?.length) return res.json({ ...c, fallback: false });
res.json({ models: FALLBACK_MODELS, fetchedAt: null, fallback: true });
});
setTimeout(() => { refreshModels().catch(() => {}); }, 3000); // boot warm-up, non-blocking
// --- Scheduled sync: config.syncEvery minutes (0 = off). Honest caveat: on
// Fly the machine sleeps when idle, so ticks run while awake; the FM-Server-
// schedule pattern in SETUP.md wakes the machine and is the reliable clock.
let autoSyncBusy = false;
async function autoSyncTick() {
try {
const c = readConfig();
const every = Number(c.syncEvery || 0);
if (!every || autoSyncBusy || !fmConfigured() || c.needsScan) return;
if (syncJob && !syncJob.done) return; // a user-started sync owns the cube right now
const include = c.includeTables || [];
if (!include.length) return;
const last = cubeManifest()?.syncedAt ? new Date(cubeManifest().syncedAt).getTime() : 0;
if (Date.now() - last < every * 60000) return;
autoSyncBusy = true;
console.log(`[auto-sync] due (${every}m); starting a sync job for ${include.length} tables`);
// As a job: visible in the app if anyone is watching, cancellable, and
// structurally incapable of overlapping any other sync.
const job = startSyncJob(include, { origin: "auto" });
await job.promise;
if (job.error) console.error("[auto-sync] failed:", job.error);
} catch (e) { console.error("[auto-sync]", e?.message || e); }
finally { autoSyncBusy = false; }
}
setInterval(autoSyncTick, 60000);
// A successful connect is evidence with an expiry date, not a permanent badge:
// a password can be revoked server-side and yesterday's success proves nothing
// about today. Stored WITHOUT the password.
function saveVerified({ host, user }) {
const c = readConfig();
c.fmVerified = { at: new Date().toISOString(), host, user };
fs.writeFileSync(CONFIG_PATH, JSON.stringify(c, null, 2));
}
function verifiedState() {
const c = readConfig(), v = c.fmVerified;
const conn = fmConnection();
if (!conn.host || !conn.user) return { state: "unset" };
// Edited since it was proven? Then it is unproven again.
if (!v || v.host !== conn.host || v.user !== conn.user) return { state: "untested" };
return { state: "verified", at: v.at };
}
app.get("/api/config", (_req, res) => {
const c = readConfig();
// AI config for the Settings panel: current model/speed/thinking + whether a
// key is set (NEVER the key itself). keyIsCustom = a key was saved in-app.
res.json({ includeTables: c.includeTables || [], displayNames: getDisplayNames(), savedAt: c.savedAt || null,
syncInstructions: c.syncInstructions || "",
needsScan: Boolean(c.needsScan), syncEvery: Number(c.syncEvery || 0),
fm: { ...fmConnection(), verified: verifiedState() }, // never the password
ai: { model: aiModel(), speed: aiSpeed(), thinking: aiThinking(), hasKey: Boolean(aiKey()), keyIsCustom: Boolean(c.aiKey), keyPreview: ((k) => k ? k.slice(0, 8) + "…" + k.slice(-4) : "")(aiKey()),
provider: isOpenAIModel(aiModel()) ? "openai" : "anthropic",
hasOpenaiKey: Boolean(openaiKey()), openaiPreview: ((k) => k ? k.slice(0, 8) + "…" + k.slice(-4) : "")(openaiKey()),
// hasKey/keyPreview answer "is the AI configured at all", so they follow
// the picked provider. The Claude card needs the ANTHROPIC key
// specifically, or it shows an OpenAI preview under an Anthropic label.
hasAnthropicKey: Boolean(c.aiKey || ENV_KEY), anthropicPreview: ((k) => k ? k.slice(0, 8) + "…" + k.slice(-4) : "")(c.aiKey || ENV_KEY) } });
});
// Lightweight base-table list for the settings panel (names + row counts only,
// no layout/relevance work) so Settings opens fast. Every successful build is
// snapshotted to the volume; when the FileMaker OData engine is down (a
// recurring event on some servers), Settings serves the snapshot with
// stale:true plus a diagnosis instead of a bare 502.
const SNAPSHOT_PATH = path.join(DATA_DIR, "schema-snapshot.json");
let tablesCache = null;
let tablesCacheAt = 0;
let tablesBuild = null; // in-flight build promise (Data API fallback can take minutes)
let scanStatus = null; // one-line progress text while a build runs; null when idle
// The ONE way to start a build. A connection save nulls tablesBuild while an
// old build still runs; when that orphan finally settles, it must not null
// the slot again and orphan the NEW build (which would let a third start).
function startTablesBuild(refresh) {
const p = buildTablesCache(refresh);
tablesBuild = p;
p.finally(() => { if (tablesBuild === p) tablesBuild = null; }).catch(() => {});
return p;
}
// The first fallback build probes every layout on the FM server and can run
// for minutes – far past proxy timeouts. So the endpoint never blocks past
// ~20s: a slow build keeps running server-side, the client gets
// {building:true} and polls. The cache is also warmed at boot.
// Are the current display names real (AI/SaXML/user) or a heuristic stopgap?
function namesProvisional() {
try { return Boolean(JSON.parse(fs.readFileSync(RELEVANCE_PATH, "utf8")).provisional); } catch { return false; }
}
// Re-run the naming pass without a full rescan: getRelevance treats a
// heuristic cache as stale the moment a key exists, so a rebuild re-asks the AI.
app.post("/api/names/refresh", async (_req, res) => {
try {
if (!aiKey()) return res.status(400).json({ error: "Set the AI key first - naming needs it." });
if (!tablesBuild) startTablesBuild(false);
await tablesBuild;
res.json({ ok: true, namesProvisional: namesProvisional() });
} catch (e) { res.status(502).json({ error: String(e.message || e) }); }
});
app.get("/api/tables", async (_req, res) => {
if (!fmConfigured()) return res.json({ notConnected: true, tables: [] });
const expired = tablesCache && tablesCache.via === "dataapi" && Date.now() - tablesCacheAt > 300000;
if (tablesCache && !_req.query.refresh && !expired) return res.json({ ...tablesCache, namesProvisional: namesProvisional() });
if (!tablesBuild) startTablesBuild(Boolean(_req.query.refresh));
const result = await Promise.race([tablesBuild, new Promise((r) => setTimeout(() => r("__building__"), 20000))]);
if (result === "__building__") {
// Serve the expired fallback cache while rebuilding, else the building flag.
return res.json(tablesCache && expired ? { ...tablesCache, namesProvisional: namesProvisional() } : { building: true });
}
// A superseded build resolves with whatever cache exists - possibly none.
// Answer "building" so the client keeps polling the build that took over.
if (!result) return res.json(tablesCache ? { ...tablesCache, namesProvisional: namesProvisional() } : { building: true });
res.json({ ...result, namesProvisional: namesProvisional() });
});
// FileMaker Server version, from the Data API's unauthenticated product-info
// endpoint. Behavior differences across FMS releases are real (OData features
// ship per version), so the connection panel shows what we're talking to.
let fmVersionCache = { host: null, at: 0, info: null };
app.get("/api/fm/version", async (_req, res) => {
const host = fmConnection().host;
if (!host) return res.json({});
if (fmVersionCache.host === host && Date.now() - fmVersionCache.at < 3600000) return res.json(fmVersionCache.info || {});
try {
const ctl = new AbortController(); const tm = setTimeout(() => ctl.abort(), 8000);
const r = await fetch(`https://${host}/fmi/data/v1/productInfo`, { signal: ctl.signal });
clearTimeout(tm);
const d = await r.json();
const p = d?.response?.productInfo || {};
fmVersionCache = { host, at: Date.now(), info: { name: p.name || "FileMaker Server", version: p.version || null, dataApiVersion: p.dateFormat ? "v1" : "v1" } };
} catch { fmVersionCache = { host, at: Date.now(), info: {} }; }
res.json(fmVersionCache.info || {});
});
// The scan popup polls this while /api/tables reports building:true.
app.get("/api/scan/progress", (_req, res) => {
if (!scanStatus) return res.json({ idle: true });
if (scanStatus.phase === "file") {
const secs = Math.floor((Date.now() - scanStatus.t0) / 1000);
// Quick files come and go with a plain line; a file at it for 5+ seconds
// gets an elapsed count that updates every 5s so the screen visibly
// moves. No editorializing about why (Matt, 2026-08-26: "dumb").
const text = secs < 5
? `Reading the structure of ${scanStatus.db}…`
: `Still reading ${scanStatus.db} — ${Math.floor(secs / 5) * 5}s so far`;
return res.json({ text });
}
res.json(scanStatus);
});
// Incremental-sync readiness, mirroring cube.js syncTable: needs a primary key
// (a `keys` entry or a field named "id") AND a modification timestamp
// (a DateTimeOffset field with "mod" in the name). Missing either = every sync
// is a full-table pull (slow). Surfaced per-table so Settings can warn.
const syncReadiness = (t) => ({
hasKey: Boolean(t.pk) || Boolean(t.keys?.[0]) || (t.fields || []).some((f) => /^id$/i.test(f.name)),
hasMod: Boolean(t.modField) || (t.fields || []).some((f) => f.type === "DateTimeOffset" && /mod/i.test(f.name)),
});
let connGen = 0; // bumped on every connection save; stale builds see it and stop
async function buildTablesCache(refresh) {
const gen = connGen;
scanStatus = { text: "Contacting the server…" };
try {
const schema = await fetchSchema(Boolean(refresh), (evt) => {
// The user changed the plan mid-scan (picked different files). This
// build is now scanning the WRONG list - it used to keep going and
// its progress lines interleaved with the new build's ("still
// scanning everything", Matt 2026-08-26). Die at the file boundary.
if (gen !== connGen) { const e = new Error("superseded by a newer connection"); e.superseded = true; throw e; }
if (evt.type === "schema-dbs") scanStatus = { text: `Found ${evt.dbs.length} database file${evt.dbs.length === 1 ? "" : "s"}` };
// A slow link makes each file's structure read take a minute or more;
// without a line at the START of each file the whole scan looks hung
// (Matt, local instance, 2026-08-26).
if (evt.type === "schema-file-start") scanStatus = { phase: "file", db: evt.db, t0: Date.now() };
if (evt.type === "schema-file" && !evt.error) scanStatus = { text: `${evt.db}: schema read · ${evt.tables} tables` };
});
const counts = await fetchCounts(schema.tables,
(done, totalN) => { if (gen === connGen) scanStatus = { text: `Counting records · ${done} of ${totalN} tables` }; },
() => gen !== connGen);
for (const t of schema.tables) t.rowCount = counts[t.name] ?? null; // attach BEFORE renames
// Apply AI homing + display naming so Settings shows tables under the
// file they live in. Uses the cached ranking when present; otherwise a
// "light" ranking (no layout stats) so the first Settings open works.
try { rehomeAndRename(schema, await getRelevance(schema, { counts: {} }, { light: true })); } catch { /* FM-only view is fine */ }
applyHints(schema); // SaXML ground truth: home file, real name, PK, mod field, comments
// A count that failed over the wire (a ~ in every occurrence name, a
// timeout) is not "no records": the cube's manifest knows how many rows
// the last sync actually landed. Never show a dash for a synced table.
try {
const manRows = new Map((cubeManifest().tables || []).map((mt) => [mt.name, mt.rows]));
for (const t of schema.tables) if (t.rowCount == null && manRows.has(t.name)) t.rowCount = manRows.get(t.name);
} catch { /* no cube yet */ }
if (!schema.tables.length && (schema.dbErrors || []).length) throw new Error(`No file reachable over OData: ${schema.dbErrors[0].error}`);
// Sizing advice belongs at scan time: this is the moment we first know
// how big the data is AND how big the machine is (Matt, 2026-08-25).
const largest = Math.max(0, ...schema.tables.map((t) => Number(t.rowCount) || 0));
const mem = machineMemory();
const recMB = recommendMemoryMB(largest);
if (gen !== connGen) { const e = new Error("superseded by a newer connection"); e.superseded = true; throw e; }
tablesCache = { db: schema.db, dbs: schema.dbs, dbErrors: schema.dbErrors || [], scannedAt: new Date().toISOString(), hints: Boolean(schema.hintsApplied),
sizing: { usableMB: mem.usableMB, recommendedMB: recMB, largestTableRows: largest,
// A nominal 1GB machine reports ~962MB usable; grace of 10%
// of the recommendation stops the false nag at exact size.
short: mem.usableMB < recMB * 0.9 },
tables: schema.tables.map((t) => ({ name: t.name, db: t.db, rowCount: t.rowCount, fields: t.fields.length, occurrences: t.occurrences.length, occurrenceNames: t.occurrences.slice(0, 20), comment: t.comment || null, ...syncReadiness(t) })) };
tablesCacheAt = Date.now();
try { fs.writeFileSync(SNAPSHOT_PATH, JSON.stringify({ savedAt: new Date().toISOString(), ...tablesCache })); } catch { /* read-only fs ok */ }
clearScanGate();
scanStatus = null;
return tablesCache;
} catch (e) {
// A superseded build is not a broken server: the user changed the plan
// and a NEWER build owns the scan now. Die quietly - running the Data API
// fallback here spent minutes probing layouts, then overwrote the fresh
// build's cache with a false "OData is not answering" diagnosis.
if (e && e.superseded) return tablesCache;
// OData is down. First choice: full Data API fallback – live tables,
// reachable through layouts, syncable (slower, full pulls). Only if THAT
// fails too do we drop to the snapshot + diagnosis.
try {
const schema = await fetchSchemaDataApi();
if (gen !== connGen) return tablesCache; // superseded while falling back
try { rehomeAndRename(schema, await getRelevance(schema, { counts: {} }, { light: true })); } catch { /* names as-is */ }
applyHints(schema); // SaXML ground truth: home file, real name, PK, mod field, comments
try {
const manRows = new Map((cubeManifest().tables || []).map((mt) => [mt.name, mt.rows]));
for (const t of schema.tables) if (t.rowCount == null && manRows.has(t.name)) t.rowCount = manRows.get(t.name);
} catch { /* no cube yet */ }
tablesCache = { db: schema.db, dbs: schema.dbs, dbErrors: schema.dbErrors || [], via: "dataapi", scannedAt: new Date().toISOString(), hints: Boolean(schema.hintsApplied),
diagnosis: "OData on your FileMaker Server is not answering right now, so Pythia is working through the Data API instead: tables stay reachable via layouts, syncs still run (full pulls unless SaXML supplies keys). This usually clears on its own; Pythia keeps checking and switches back automatically.",
tables: schema.tables.map((t) => ({ name: t.name, db: t.db, rowCount: t.rowCount, fields: t.fields.length, occurrences: t.occurrences.length, occurrenceNames: t.occurrences.slice(0, 20), comment: t.comment || null, ...syncReadiness(t) })) };
tablesCacheAt = Date.now();
clearScanGate();
scanStatus = null;
return tablesCache;
} catch { /* Data API fallback also failed */ }
scanStatus = null;
let serverFiles = null;
try { serverFiles = await listDatabasesDataApi(); } catch { /* whole server down */ }
let snapshot = null;
try { snapshot = JSON.parse(fs.readFileSync(SNAPSHOT_PATH, "utf8")); } catch { /* none yet */ }
const diagnosis = serverFiles
? "OData on your FileMaker Server is not answering, though the server itself is up (the Data API responds). Pythia will keep retrying; this usually clears on its own. If it persists for hours, mention it to whoever manages that FileMaker Server."
: "Your FileMaker Server is not responding at all right now. Check that the machine is up and reachable from the internet.";
return { ...(snapshot || { db: null, dbs: [], dbErrors: [], tables: [] }), stale: true,
staleSavedAt: snapshot?.savedAt || null, serverFiles, error: String(e.message || e), diagnosis };
}
}
// Save table selection and/or friendly display names. Merges into the existing
// config so it never clobbers dataSource or the other field.
app.post("/api/config", async (req, res) => {
const config = readConfig();
if (Array.isArray(req.body?.includeTables)) config.includeTables = req.body.includeTables;
if (req.body?.displayNames && typeof req.body.displayNames === "object") config.displayNames = { ...(config.displayNames || {}), ...req.body.displayNames };
if (typeof req.body?.syncInstructions === "string") config.syncInstructions = req.body.syncInstructions.slice(0, 20000);
const ai = req.body?.ai;
if (ai && typeof ai === "object") {
// A key slot must never accept a URL. A provider-switch bug once sent a
// server URL into aiKey and DESTROYED a stored Anthropic key with no
// warning (2026-08-26). Overwriting a secret is unrecoverable, so the
// shape is checked here rather than trusted from the caller.
const looksLikeUrl = (v) => /^https?:\/\//i.test(v) || /^[a-z0-9.-]+:\d{2,5}$/i.test(v);
if (typeof ai.aiKey === "string" && ai.aiKey.trim()) {
if (looksLikeUrl(ai.aiKey.trim())) return res.status(400).json({ error: "That looks like a server URL, not an API key. The Anthropic key was left unchanged." });
config.aiKey = ai.aiKey.trim(); // blank => keep current key
}
// OpenAI lives beside Anthropic, never replacing it: the model picked
// decides which key is used, so both can be stored at once.
if (typeof ai.openaiKey === "string" && ai.openaiKey.trim()) {
if (looksLikeUrl(ai.openaiKey.trim())) return res.status(400).json({ error: "That looks like a server URL, not an API key. The OpenAI key was left unchanged." });
config.openaiKey = ai.openaiKey.trim();
}
if (typeof ai.model === "string") {
config.aiModel = ai.model;
// Picking anything other than the family's current model is a deliberate pin.
const newest = newestOfFamily(modelFamily(ai.model));
config.aiModelPinned = Boolean(newest && newest !== ai.model);
}
if (typeof ai.speed === "string") config.aiSpeed = ai.speed;
if (typeof ai.thinking === "string") config.aiThinking = ai.thinking;
}
if (req.body?.syncEvery !== undefined) config.syncEvery = Math.max(0, Number(req.body.syncEvery) || 0);
const ctx = req.body?.context;
if (ctx && typeof ctx === "object") {
if (typeof ctx.aboutDatabase === "string") { config.aboutDatabase = ctx.aboutDatabase.trim(); config.contextEdited = true; }
if (typeof ctx.reportingPriorities === "string") config.reportingPriorities = ctx.reportingPriorities.trim();
}
config.savedAt = new Date().toISOString();
if (req.body?.fm && typeof req.body.fm === "object") {
const cur = fmConnection(), nf = req.body.fm;
const changed = (nf.host !== undefined && String(nf.host).trim() !== cur.host)
|| (nf.user !== undefined && String(nf.user).trim() !== cur.user)
|| (typeof nf.pass === "string" && nf.pass !== "");
if (changed) delete config.fmVerified; // proof belongs to the OLD credentials
}
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
if (req.body?.fm && typeof req.body.fm === "object") saveFmConnection(req.body.fm);
res.json({ saved: true }); // never echo the config back (holds the key + password)
});
// Verify a FileMaker connection from Settings: apply the entered values, persist
// them, then ask the server which databases this account can actually see.
app.post("/api/fm/test", async (req, res) => {
const fm = req.body?.fm || {};
// persist:false (the Settings UI) = test the candidate transiently; nothing
// is written unless the user Saves. Default (old behavior) persists first.
const persist = req.body?.persist !== false;
const authFail = { ok: false, error: "The server answered, but the username/password was rejected (or the account lacks the fmodata privilege)." };
const doTest = async () => {
if (!fmConfigured()) return { ok: false, error: "Fill in server, username, and password." };
let databases = [], via = "odata";
// FMS doesn't always answer a bad login with 401 on the OData root: it can
// return 501 carrying FileMaker error 9 (insufficient privileges), which
// used to reach the user as a raw JSON dump. Same cause, same message.
const isAuthErr = (e) => {
const s = String(e?.message || e);
return /\b401\b|unauthorized/i.test(s) || (/\b501\b/.test(s) && /"code":\s*"?9"?/.test(s));
};
try { databases = await listDatabases(); }
catch (e) {
if (isAuthErr(e)) return authFail;
try { databases = await listDatabasesDataApi(); via = "dataapi"; } catch { throw e; }
}
const want = fmConnection().db;
// want is a comma list (or blank for "every visible file"), so every name
// gets checked — testing only the first reported a clean connection while
// later files in the list were quietly unreachable.
const wanted = want.split(",").map((s) => s.trim()).filter(Boolean);
const missing = wanted.filter((w) => !databases.includes(w));
// The root list proves the SERVER answers, not that the login works (the
// root and the Data API list both answer without auth on many servers).
// The real credential check is opening one actual file over OData.
let credentialsProven = via !== "odata"; // dataapi listing already needed auth
if (via === "odata") {
const target = wanted.find((w) => databases.includes(w)) || databases[0];
if (target) {
try { await checkAuth(target); credentialsProven = true; }