@klhapp/skillmux 1.9.3 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,6 +4,8 @@ import { join } from "node:path";
4
4
  import { buildAuditRow } from "./audit";
5
5
  import { embeddingDimension, embeddingFingerprint, expandHome, loadConfig } from "./config";
6
6
  import { RemoteInferenceError } from "./clients";
7
+ import { log } from "./logger";
8
+ import { warn } from "./output";
7
9
  import {
8
10
  deleteSkill,
9
11
  findExactMatch,
@@ -274,7 +276,7 @@ export async function syncVaultIfNeeded(): Promise<void> {
274
276
  const invalidIds: string[] = [];
275
277
  const skills = await scanVaults(vaultPath, localVaultPaths, (skillId, error) => {
276
278
  invalidIds.push(skillId);
277
- console.error(`warning: keeping previous index entry for ${skillId}: ${error}`);
279
+ warn(`keeping previous index entry for ${skillId}: ${error}`);
278
280
  });
279
281
  const rows = skills.map(toSkillRow);
280
282
  // See rebuildIndex: a skill_id invalid in one root can still be valid in
@@ -361,7 +363,7 @@ async function reindexOneSkill(db: Database, vaultPath: string, skillId: string)
361
363
  upsertSkill(db, await readSkill(vaultPath, skillId));
362
364
  backfillEmbeddings().catch(() => {});
363
365
  } catch (error) {
364
- console.error(`warning: keeping previous index entry for ${skillId}: ${error}`);
366
+ warn(`keeping previous index entry for ${skillId}: ${error}`);
365
367
  }
366
368
  }
367
369
 
@@ -395,7 +397,7 @@ export async function startVaultWatcher(): Promise<() => void> {
395
397
  // A watcher error (e.g. the vault root disappearing) must degrade the index,
396
398
  // not crash the server — an unhandled 'error' event would throw.
397
399
  watcher.on("error", (error) => {
398
- console.error(`warning: vault watcher error, live updates paused: ${error}`);
400
+ warn(`vault watcher error, live updates paused: ${error}`);
399
401
  });
400
402
 
401
403
  return () => {
@@ -554,14 +556,7 @@ export async function retrieveAndRerank(
554
556
  retrieval = "lexical";
555
557
  degraded_from = clients.rerank ? "reranked" : "hybrid";
556
558
  degradation_reason = classifyInferenceError("embedding", embedRes.error);
557
- console.error(
558
- JSON.stringify({
559
- level: "warn",
560
- stage: "embedding",
561
- degraded_from,
562
- reason: degradation_reason,
563
- }),
564
- );
559
+ log.warn("embedding", { degraded_from, reason: degradation_reason });
565
560
  } else {
566
561
  try {
567
562
  const queryVec = (embedRes as Float32Array[])[0];
@@ -574,14 +569,7 @@ export async function retrieveAndRerank(
574
569
  retrieval = "lexical";
575
570
  degraded_from = clients.rerank ? "reranked" : "hybrid";
576
571
  degradation_reason = classifyInferenceError("embedding", embedError);
577
- console.error(
578
- JSON.stringify({
579
- level: "warn",
580
- stage: "embedding",
581
- degraded_from,
582
- reason: degradation_reason,
583
- }),
584
- );
572
+ log.warn("embedding", { degraded_from, reason: degradation_reason });
585
573
  }
586
574
  }
587
575
  }
@@ -601,14 +589,7 @@ export async function retrieveAndRerank(
601
589
  scores = null;
602
590
  degraded_from = "reranked";
603
591
  degradation_reason = classifyInferenceError("reranker", rerankError);
604
- console.error(
605
- JSON.stringify({
606
- level: "warn",
607
- stage: "reranker",
608
- degraded_from,
609
- reason: degradation_reason,
610
- }),
611
- );
592
+ log.warn("reranker", { degraded_from, reason: degradation_reason });
612
593
  }
613
594
  }
614
595
 
package/src/server.ts CHANGED
@@ -16,13 +16,15 @@ import {
16
16
  resolveSkill,
17
17
  } from "./router-core";
18
18
  import { closeRuntime, getRuntime, startVaultWatcher } from "./router-core";
19
- import { getStats, SINCE_PATTERN } from "./stats";
19
+ import { getStats, parseSince, SINCE_PATTERN } from "./stats";
20
+ import { countPrunable, insertAdminAuditRow, pruneAuditBefore, type AdminAuditChange } from "./db";
21
+ import { buildPromotedCases, evalVault, queryPromotableFetches } from "./eval";
20
22
  import { SKILL_ID_PATTERN } from "./vault";
21
23
  import { MetricsRegistry } from "./metrics";
22
24
  import { ReadinessState } from "./readiness";
23
25
  import { initializeRuntime } from "./lifecycle";
24
26
  import { buildRedactor } from "./redact";
25
- import { insertAdminAuditRow, type AdminAuditChange } from "./db";
27
+ import { redactedErrorLog } from "./logger";
26
28
  import type { Clients, Config } from "./types";
27
29
  import {
28
30
  computeHash,
@@ -41,16 +43,6 @@ export const readinessState = new ReadinessState();
41
43
  const DEFAULT_MAX_BODY_BYTES = 1_048_576; // 1 MiB
42
44
  const DEFAULT_MAX_CONCURRENT_REQUESTS = 100;
43
45
 
44
- /** Pairs a fixed log prefix with a redacted error message, for console.error. */
45
- export function redactedErrorLog(
46
- prefix: string,
47
- err: unknown,
48
- redact: (text: string) => string,
49
- ): [string, string] {
50
- const msg = err instanceof Error ? err.message : String(err);
51
- return [prefix, redact(msg)];
52
- }
53
-
54
46
  export interface ServerHandle {
55
47
  port?: number;
56
48
  statsPort?: number;
@@ -409,7 +401,7 @@ export async function startServer(opts?: {
409
401
  allowed_origins: [],
410
402
  };
411
403
  const origin = req.headers.get("origin") || "";
412
- const allowedOrigins = serverConfig.allowed_origins;
404
+ const allowedOrigins = serverConfig.allowed_origins || [];
413
405
  const isAllowed =
414
406
  allowedOrigins.includes("*") || allowedOrigins.includes(origin);
415
407
  const allowOriginHeader = isAllowed
@@ -700,6 +692,161 @@ export async function startServer(opts?: {
700
692
  });
701
693
  }
702
694
 
695
+ if (
696
+ req.method === "POST" &&
697
+ url.pathname === "/admin/v1/audit/prune"
698
+ ) {
699
+ let body: {
700
+ older_than?: string;
701
+ dry_run?: boolean;
702
+ confirm?: boolean;
703
+ } = {};
704
+ try {
705
+ const text = await req.text();
706
+ if (text.trim()) {
707
+ body = JSON.parse(text);
708
+ }
709
+ } catch {
710
+ return new Response(
711
+ JSON.stringify({
712
+ error: "INVALID_JSON",
713
+ message: "Request body must be valid JSON",
714
+ }),
715
+ { status: 400, headers },
716
+ );
717
+ }
718
+
719
+ const dryRun = body.dry_run ?? false;
720
+ const confirm = body.confirm ?? false;
721
+ if (!dryRun && !confirm) {
722
+ return new Response(
723
+ JSON.stringify({
724
+ error: "CONFIRMATION_REQUIRED",
725
+ message:
726
+ "Non-dry-run audit prune requires confirm: true",
727
+ }),
728
+ { status: 400, headers },
729
+ );
730
+ }
731
+
732
+ const { effective } = await getEffectiveConfig(configPath);
733
+ let cutoff: Date;
734
+ if (body.older_than) {
735
+ try {
736
+ cutoff = parseSince(body.older_than);
737
+ } catch (err: any) {
738
+ return new Response(
739
+ JSON.stringify({
740
+ error: "INVALID_CUTOFF",
741
+ message: err.message,
742
+ }),
743
+ { status: 400, headers },
744
+ );
745
+ }
746
+ } else {
747
+ const retentionDays = effective.audit?.retention_days ?? 90;
748
+ if (retentionDays <= 0) {
749
+ return new Response(
750
+ JSON.stringify({
751
+ audit_deleted: 0,
752
+ fetch_deleted: 0,
753
+ admin_audit_deleted: 0,
754
+ dry_run: dryRun,
755
+ cutoff: null,
756
+ }),
757
+ { status: 200, headers },
758
+ );
759
+ }
760
+ cutoff = new Date(Date.now() - retentionDays * 86_400_000);
761
+ }
762
+ const cutoffIso = cutoff.toISOString();
763
+
764
+ const { auditDb } = await getRuntime();
765
+ if (dryRun) {
766
+ const counts = countPrunable(auditDb, cutoffIso);
767
+ return new Response(
768
+ JSON.stringify({
769
+ ...counts,
770
+ dry_run: true,
771
+ cutoff: cutoffIso,
772
+ }),
773
+ { status: 200, headers },
774
+ );
775
+ }
776
+
777
+ const counts = pruneAuditBefore(auditDb, cutoffIso);
778
+ return new Response(
779
+ JSON.stringify({
780
+ ...counts,
781
+ dry_run: false,
782
+ cutoff: cutoffIso,
783
+ }),
784
+ { status: 200, headers },
785
+ );
786
+ }
787
+
788
+ if (req.method === "POST" && url.pathname === "/admin/v1/eval") {
789
+ const report = await evalVault();
790
+ return new Response(JSON.stringify(report), {
791
+ status: 200,
792
+ headers,
793
+ });
794
+ }
795
+
796
+ if (
797
+ req.method === "POST" &&
798
+ url.pathname === "/admin/v1/eval/promote"
799
+ ) {
800
+ let body: { since?: string } = {};
801
+ try {
802
+ const text = await req.text();
803
+ if (text.trim()) {
804
+ body = JSON.parse(text);
805
+ }
806
+ } catch {
807
+ return new Response(
808
+ JSON.stringify({
809
+ error: "INVALID_JSON",
810
+ message: "Request body must be valid JSON",
811
+ }),
812
+ { status: 400, headers },
813
+ );
814
+ }
815
+
816
+ if (!body.since || typeof body.since !== "string") {
817
+ return new Response(
818
+ JSON.stringify({
819
+ error: "MISSING_SINCE",
820
+ message: "Field 'since' is required",
821
+ }),
822
+ { status: 400, headers },
823
+ );
824
+ }
825
+
826
+ let sinceDate: Date;
827
+ try {
828
+ sinceDate = parseSince(body.since);
829
+ } catch (err: any) {
830
+ return new Response(
831
+ JSON.stringify({
832
+ error: "INVALID_SINCE",
833
+ message: err.message,
834
+ }),
835
+ { status: 400, headers },
836
+ );
837
+ }
838
+ const sinceIso = sinceDate.toISOString();
839
+
840
+ const { auditDb } = await getRuntime();
841
+ const candidates = buildPromotedCases(
842
+ queryPromotableFetches(auditDb, sinceIso),
843
+ );
844
+ return new Response(JSON.stringify({ candidates }), {
845
+ status: 200,
846
+ headers,
847
+ });
848
+ }
849
+
703
850
  return new Response("Not Found", { status: 404, headers });
704
851
  }
705
852
 
@@ -0,0 +1,51 @@
1
+ export function stringifyToml(obj: Record<string, any>): string {
2
+ let out = "";
3
+ const topLevel: Record<string, any> = {};
4
+ const sections: Record<string, any> = {};
5
+
6
+ for (const [k, v] of Object.entries(obj)) {
7
+ if (typeof v === "object" && v !== null && !Array.isArray(v)) {
8
+ sections[k] = v;
9
+ } else {
10
+ topLevel[k] = v;
11
+ }
12
+ }
13
+
14
+ for (const [k, v] of Object.entries(topLevel)) {
15
+ out += `${k} = ${formatTomlVal(v)}\n`;
16
+ }
17
+ if (Object.keys(topLevel).length > 0) out += "\n";
18
+
19
+ for (const [secName, secObj] of Object.entries(sections)) {
20
+ out += stringifyTomlSection([secName], secObj);
21
+ }
22
+
23
+ return out;
24
+ }
25
+
26
+ export function stringifyTomlSection(path: string[], obj: Record<string, any>): string {
27
+ let out = `[${path.join(".")}]\n`;
28
+ const subSections: Record<string, any> = {};
29
+
30
+ for (const [k, v] of Object.entries(obj)) {
31
+ if (typeof v === "object" && v !== null && !Array.isArray(v)) {
32
+ subSections[k] = v;
33
+ } else {
34
+ out += `${k} = ${formatTomlVal(v)}\n`;
35
+ }
36
+ }
37
+ out += "\n";
38
+
39
+ for (const [subName, subObj] of Object.entries(subSections)) {
40
+ out += stringifyTomlSection([...path, subName], subObj);
41
+ }
42
+
43
+ return out;
44
+ }
45
+
46
+ export function formatTomlVal(v: unknown): string {
47
+ if (typeof v === "string") return JSON.stringify(v);
48
+ if (typeof v === "boolean" || typeof v === "number") return String(v);
49
+ if (Array.isArray(v)) return JSON.stringify(v);
50
+ return JSON.stringify(v);
51
+ }