@bothat-io/molenkopf 0.1.2

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.
Files changed (147) hide show
  1. package/.env.example +2 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -0
  4. package/SECURITY.md +36 -0
  5. package/bin/launcher.js +76 -0
  6. package/bin/molenkopf.js +4 -0
  7. package/docs/DEPLOYMENT.md +104 -0
  8. package/docs/MOLENKOPF_PLUGIN_API.md +113 -0
  9. package/docs/MOLENKOPF_PROVIDER_ENV.md +123 -0
  10. package/docs/MOLENKOPF_USAGE.md +195 -0
  11. package/docs/PRODUCT_INTENT.md +36 -0
  12. package/docs/THREAT_MODEL.md +94 -0
  13. package/molenkopf.config.example.json +68 -0
  14. package/package.json +98 -0
  15. package/packages/core/src/auth/password.ts +47 -0
  16. package/packages/core/src/auth/session.ts +64 -0
  17. package/packages/core/src/ci/ci-mode.ts +71 -0
  18. package/packages/core/src/compression/content-classifier.ts +25 -0
  19. package/packages/core/src/compression/context-compressor.ts +48 -0
  20. package/packages/core/src/compression/json-compressor.ts +54 -0
  21. package/packages/core/src/compression/log-compressor.ts +32 -0
  22. package/packages/core/src/compression/operational-block-compressor.ts +43 -0
  23. package/packages/core/src/compression/stacktrace-compressor.ts +23 -0
  24. package/packages/core/src/config/config-policies.ts +146 -0
  25. package/packages/core/src/config/molenkopf-config.ts +137 -0
  26. package/packages/core/src/config/provider-config.ts +139 -0
  27. package/packages/core/src/events/event-bus.ts +88 -0
  28. package/packages/core/src/identity/api-keys.ts +149 -0
  29. package/packages/core/src/identity/budget.ts +51 -0
  30. package/packages/core/src/identity/db.ts +68 -0
  31. package/packages/core/src/identity/identity-store.ts +175 -0
  32. package/packages/core/src/identity/identity-validation.ts +102 -0
  33. package/packages/core/src/identity/key-permissions.ts +18 -0
  34. package/packages/core/src/identity/pricing.ts +11 -0
  35. package/packages/core/src/identity/types.ts +87 -0
  36. package/packages/core/src/identity/usage-snapshot.ts +116 -0
  37. package/packages/core/src/manifest/audit-activity.ts +74 -0
  38. package/packages/core/src/manifest/audit-metrics.ts +7 -0
  39. package/packages/core/src/manifest/audit-safety.ts +113 -0
  40. package/packages/core/src/manifest/audit-store.ts +189 -0
  41. package/packages/core/src/manifest/audit-summary.ts +184 -0
  42. package/packages/core/src/manifest/usage-meter.ts +105 -0
  43. package/packages/core/src/memory/memory-extractor.ts +57 -0
  44. package/packages/core/src/memory/memory-graph.ts +55 -0
  45. package/packages/core/src/pipeline/json-string-spans.ts +143 -0
  46. package/packages/core/src/pipeline/openai-request-rewriter.ts +66 -0
  47. package/packages/core/src/plugins/builtin-plugin-descriptors.ts +10 -0
  48. package/packages/core/src/plugins/builtin-plugin-modules.ts +9 -0
  49. package/packages/core/src/plugins/plugin-api.ts +96 -0
  50. package/packages/core/src/plugins/plugin-catalog.ts +42 -0
  51. package/packages/core/src/plugins/plugin-descriptor.ts +51 -0
  52. package/packages/core/src/plugins/plugin-sdk.ts +47 -0
  53. package/packages/core/src/plugins/static-pipeline.ts +5 -0
  54. package/packages/core/src/profiles/profile-router.ts +45 -0
  55. package/packages/core/src/providers/provider-catalog.ts +186 -0
  56. package/packages/core/src/routing/distribution.ts +31 -0
  57. package/packages/core/src/security/secret-redactor.ts +139 -0
  58. package/packages/core/src/security/target-policy.ts +61 -0
  59. package/packages/core/src/storage/local-paths.ts +6 -0
  60. package/packages/core/src/storage/private-state.ts +30 -0
  61. package/packages/core/src/storage/purge-dir.ts +10 -0
  62. package/packages/core/src/store/retrieval-store.ts +114 -0
  63. package/packages/core/src/utils/hash.ts +9 -0
  64. package/packages/core/src/utils/text.ts +18 -0
  65. package/packages/core/src/utils/tokens.ts +3 -0
  66. package/packages/dashboard/dist/assets/index-B_aSPgHx.js +11 -0
  67. package/packages/dashboard/dist/assets/index-D6z2TEL2.css +1 -0
  68. package/packages/dashboard/dist/favicon.png +0 -0
  69. package/packages/dashboard/dist/index.html +15 -0
  70. package/packages/dashboard/dist/molenkopf-logo.png +0 -0
  71. package/packages/dashboard/public/favicon.png +0 -0
  72. package/packages/dashboard/public/molenkopf-logo.png +0 -0
  73. package/packages/plugins/context-compressor-plugin/descriptor.ts +19 -0
  74. package/packages/plugins/context-compressor-plugin/page.html +191 -0
  75. package/packages/plugins/context-compressor-plugin/plugin.ts +40 -0
  76. package/packages/plugins/obsidian-graph-plugin/descriptor.ts +19 -0
  77. package/packages/plugins/obsidian-graph-plugin/page.html +68 -0
  78. package/packages/plugins/obsidian-graph-plugin/plugin.ts +27 -0
  79. package/packages/plugins/shared/audit-projects.ts +32 -0
  80. package/packages/proxy/src/cli/args.ts +34 -0
  81. package/packages/proxy/src/cli/config-loader.ts +43 -0
  82. package/packages/proxy/src/cli/env-file.ts +43 -0
  83. package/packages/proxy/src/cli/main.ts +132 -0
  84. package/packages/proxy/src/cli/profile-server.ts +176 -0
  85. package/packages/proxy/src/cli/target.ts +7 -0
  86. package/packages/proxy/src/http/agent-drafts.ts +103 -0
  87. package/packages/proxy/src/http/agent-router.ts +69 -0
  88. package/packages/proxy/src/http/audit-view.ts +15 -0
  89. package/packages/proxy/src/http/auth-state.ts +44 -0
  90. package/packages/proxy/src/http/budget-gate.ts +45 -0
  91. package/packages/proxy/src/http/budget-warnings.ts +7 -0
  92. package/packages/proxy/src/http/cli-stream-response.ts +51 -0
  93. package/packages/proxy/src/http/client-identity.ts +51 -0
  94. package/packages/proxy/src/http/communication-graph.ts +139 -0
  95. package/packages/proxy/src/http/control-plane-guard.ts +56 -0
  96. package/packages/proxy/src/http/dashboard-assets.ts +115 -0
  97. package/packages/proxy/src/http/encoded-usage-meter.ts +32 -0
  98. package/packages/proxy/src/http/header-utils.ts +65 -0
  99. package/packages/proxy/src/http/identity-id.ts +11 -0
  100. package/packages/proxy/src/http/local-api-agent-actions.ts +17 -0
  101. package/packages/proxy/src/http/local-api-auth.ts +120 -0
  102. package/packages/proxy/src/http/local-api-consumer-actions.ts +20 -0
  103. package/packages/proxy/src/http/local-api-identity.ts +194 -0
  104. package/packages/proxy/src/http/local-api-io.ts +82 -0
  105. package/packages/proxy/src/http/local-api-keys.ts +126 -0
  106. package/packages/proxy/src/http/local-api-pipeline.ts +41 -0
  107. package/packages/proxy/src/http/local-api-plugin-actions.ts +31 -0
  108. package/packages/proxy/src/http/local-api-provider-actions.ts +181 -0
  109. package/packages/proxy/src/http/local-api-retention.ts +28 -0
  110. package/packages/proxy/src/http/local-api-runtime-auth.ts +119 -0
  111. package/packages/proxy/src/http/local-api-scope.ts +47 -0
  112. package/packages/proxy/src/http/local-api-state.ts +180 -0
  113. package/packages/proxy/src/http/local-api.ts +166 -0
  114. package/packages/proxy/src/http/password-policy.ts +5 -0
  115. package/packages/proxy/src/http/plugin-data.ts +38 -0
  116. package/packages/proxy/src/http/plugin-host.ts +87 -0
  117. package/packages/proxy/src/http/plugin-modules.ts +1 -0
  118. package/packages/proxy/src/http/plugin-page-loader.ts +24 -0
  119. package/packages/proxy/src/http/plugin-pipeline.ts +125 -0
  120. package/packages/proxy/src/http/provider-access.ts +33 -0
  121. package/packages/proxy/src/http/provider-http-test.ts +133 -0
  122. package/packages/proxy/src/http/provider-input.ts +39 -0
  123. package/packages/proxy/src/http/provider-routing-snapshot.ts +28 -0
  124. package/packages/proxy/src/http/provider-test.ts +149 -0
  125. package/packages/proxy/src/http/proxy-identity.ts +78 -0
  126. package/packages/proxy/src/http/public-bind.ts +8 -0
  127. package/packages/proxy/src/http/request-finish.ts +62 -0
  128. package/packages/proxy/src/http/request-path.ts +8 -0
  129. package/packages/proxy/src/http/request-policy.ts +46 -0
  130. package/packages/proxy/src/http/runtime-auth-proof.ts +55 -0
  131. package/packages/proxy/src/http/runtime-auth-registry.ts +105 -0
  132. package/packages/proxy/src/http/runtime-settings.ts +199 -0
  133. package/packages/proxy/src/http/runtime-state.ts +198 -0
  134. package/packages/proxy/src/http/server-io.ts +80 -0
  135. package/packages/proxy/src/http/server-types.ts +17 -0
  136. package/packages/proxy/src/http/server.ts +190 -0
  137. package/packages/proxy/src/http/session-secret.ts +19 -0
  138. package/packages/proxy/src/http/streaming-proxy.ts +88 -0
  139. package/packages/proxy/src/http/usage-accounting.ts +100 -0
  140. package/packages/proxy/src/http/usage-restore.ts +15 -0
  141. package/packages/proxy/src/runtime/cli-diagnostics.ts +64 -0
  142. package/packages/proxy/src/runtime/cli-env.ts +22 -0
  143. package/packages/proxy/src/runtime/cli-executor.ts +134 -0
  144. package/packages/proxy/src/runtime/cli-provider.ts +162 -0
  145. package/packages/proxy/src/runtime/cli-request.ts +79 -0
  146. package/packages/proxy/src/runtime/codex-runtime-config.ts +37 -0
  147. package/packages/proxy/src/runtime/runtime-profile.ts +170 -0
@@ -0,0 +1,64 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+
3
+ // Stateless signed session tokens: base64(userId.expiry).hmac. Runtime servers
4
+ // must supply a stable secret; newSessionSecret is for tests and utilities.
5
+
6
+ const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000;
7
+ export type SessionPayload = { userId: string; sessionVersion: number };
8
+
9
+ export function newSessionSecret(): string {
10
+ return randomBytes(32).toString("hex");
11
+ }
12
+
13
+ export function signSession(userId: string, secret: string, ttlMs = DEFAULT_TTL_MS, now = Date.now(), sessionVersion = 0): string {
14
+ const body = Buffer.from(JSON.stringify({ u: userId, v: sessionVersion, e: now + ttlMs })).toString("base64url");
15
+ return `${body}.${hmac(body, secret)}`;
16
+ }
17
+
18
+ export function verifySession(token: string | undefined, secret: string, now = Date.now()): string | undefined {
19
+ return verifySessionPayload(token, secret, now)?.userId;
20
+ }
21
+
22
+ export function verifySessionPayload(token: string | undefined, secret: string, now = Date.now()): SessionPayload | undefined {
23
+ if (!token || !token.includes(".")) return undefined;
24
+ const parts = token.split(".");
25
+ if (parts.length !== 2) return undefined;
26
+ const [body, sig] = parts;
27
+ if (!body || !sig || !equals(sig, hmac(body, secret))) return undefined;
28
+ const decoded = Buffer.from(body, "base64url").toString("utf8");
29
+ const payload = parsePayload(decoded);
30
+ if (!payload) return undefined;
31
+ const { userId, sessionVersion, expiry } = payload;
32
+ if (!Number.isSafeInteger(sessionVersion) || sessionVersion < 0) return undefined;
33
+ if (!Number.isFinite(expiry) || expiry < now) return undefined;
34
+ return { userId, sessionVersion };
35
+ }
36
+
37
+ function parsePayload(decoded: string): (SessionPayload & { expiry: number }) | undefined {
38
+ if (decoded.startsWith("{")) {
39
+ try {
40
+ const parsed = JSON.parse(decoded) as { u?: unknown; v?: unknown; e?: unknown };
41
+ if (typeof parsed.u === "string") return { userId: parsed.u, sessionVersion: Number(parsed.v), expiry: Number(parsed.e) };
42
+ } catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ const parts = decoded.split(".");
47
+ if (parts.length < 2) return undefined;
48
+ const expiry = Number(parts.at(-1));
49
+ const maybeVersion = Number(parts.at(-2));
50
+ if (parts.length >= 3 && Number.isSafeInteger(maybeVersion) && maybeVersion >= 0) {
51
+ return { userId: parts.slice(0, -2).join("."), sessionVersion: maybeVersion, expiry };
52
+ }
53
+ return { userId: parts.slice(0, -1).join("."), sessionVersion: 0, expiry };
54
+ }
55
+
56
+ function hmac(body: string, secret: string): string {
57
+ return createHmac("sha256", secret).update(body).digest("base64url");
58
+ }
59
+
60
+ function equals(a: string, b: string): boolean {
61
+ const x = Buffer.from(a);
62
+ const y = Buffer.from(b);
63
+ return x.length === y.length && timingSafeEqual(x, y);
64
+ }
@@ -0,0 +1,71 @@
1
+ import { redactSecrets } from "../security/secret-redactor.ts";
2
+
3
+ export type PrFile = { path: string; patch: string };
4
+ export type PrContextInput = { title: string; description?: string; files: PrFile[] };
5
+ export type PrContextLimits = { maxFiles?: number; maxFieldChars?: number; maxPatchChars?: number; maxTotalChars?: number };
6
+
7
+ const DEFAULT_LIMITS = { maxFiles: 100, maxFieldChars: 500, maxPatchChars: 4000, maxTotalChars: 16000 };
8
+
9
+ export function packPrContext(input: PrContextInput, limitsInput: PrContextLimits = {}): string {
10
+ const limits = { ...DEFAULT_LIMITS, ...limitsInput };
11
+ if (input.files.length > limits.maxFiles) throw new Error("too_many_pr_files");
12
+ const lines = ["# PR Context", `title: ${safeField(input.title, limits.maxFieldChars)}`];
13
+ if (input.description) lines.push(`description: ${safeField(input.description, limits.maxFieldChars)}`);
14
+ let omittedFiles = 0;
15
+ for (const file of input.files) {
16
+ const block = fileBlock(file, limits.maxPatchChars);
17
+ if (joinedLength(lines, block) > limits.maxTotalChars) {
18
+ omittedFiles++;
19
+ continue;
20
+ }
21
+ lines.push(block);
22
+ }
23
+ if (omittedFiles && joinedLength(lines, omittedMarker(omittedFiles)) <= limits.maxTotalChars) lines.push(omittedMarker(omittedFiles));
24
+ return truncate(lines.join("\n"), limits.maxTotalChars, "context");
25
+ }
26
+
27
+ export function createCiAuditArtifact(input: { requestId: string; savedTokens: number; retrievalIds: string[] }) {
28
+ return {
29
+ mode: "ci",
30
+ remoteIssueIntegration: false,
31
+ createdAt: new Date().toISOString(),
32
+ requestId: input.requestId,
33
+ savedTokens: input.savedTokens,
34
+ retrievalIds: input.retrievalIds
35
+ };
36
+ }
37
+
38
+ function fileBlock(file: PrFile, maxPatchChars: number): string {
39
+ const patch = safePatch(file.patch, maxPatchChars);
40
+ return `\n## ${safePath(file.path)}\n${patch}`;
41
+ }
42
+
43
+ function safeField(value: string, maxChars: number): string {
44
+ return truncate(redactSecrets(value).text.replace(/\s+/g, " ").trim(), maxChars, "field");
45
+ }
46
+
47
+ function safePath(value: string): string {
48
+ const redacted = redactSecrets(value).text.replace(/\\/g, "/");
49
+ const parts = redacted.split("/").filter((part) => part && part !== "." && part !== "..");
50
+ const normalized = parts.join("/") || "unknown";
51
+ return truncate(normalized.replace(/[^\w .@:/\-[\]]/g, "_"), 180, "path");
52
+ }
53
+
54
+ function safePatch(value: string, maxChars: number): string {
55
+ return truncate(redactSecrets(value).text, maxChars, "patch");
56
+ }
57
+
58
+ function truncate(value: string, maxChars: number, label: string): string {
59
+ if (value.length <= maxChars) return value;
60
+ const marker = `\n[molenkopf omitted: ${value.length - maxChars} ${label} chars]`;
61
+ if (marker.length >= maxChars) return value.slice(0, Math.max(0, maxChars));
62
+ return `${value.slice(0, maxChars - marker.length)}${marker}`;
63
+ }
64
+
65
+ function joinedLength(lines: string[], next: string): number {
66
+ return lines.concat(next).join("\n").length;
67
+ }
68
+
69
+ function omittedMarker(count: number): string {
70
+ return `[molenkopf omitted: ${count} files after total context limit]`;
71
+ }
@@ -0,0 +1,25 @@
1
+ export type ContentKind =
2
+ | "json" | "stacktrace" | "log" | "shell_output" | "markdown"
3
+ | "source_code" | "diff" | "plain_text" | "unknown";
4
+
5
+ export function classifyContent(text: string, filename = ""): ContentKind {
6
+ const trimmed = text.trim();
7
+ if (!trimmed) return "plain_text";
8
+ try {
9
+ JSON.parse(trimmed);
10
+ return "json";
11
+ } catch {}
12
+ if (/^diff --git|^@@\s|^\+\+\+ |^--- /m.test(text)) return "diff";
13
+ if (/Traceback|Exception|Error:|^\s+at .+\(.+:\d+:\d+\)|File ".+", line \d+/m.test(text)) return "stacktrace";
14
+ if (/\.(ts|tsx|js|mjs|cjs|json|lock|sql|py|go|rs)$/i.test(filename)) return "source_code";
15
+ if (/\b(import|export|function|class|interface|type)\b|=>|[{;]\s*$/m.test(text)) return "source_code";
16
+ if (/^\s{0,3}#{1,6}\s|^\s*[-*]\s|\|.+\|/m.test(text)) return "markdown";
17
+ if (/\b(npm|pnpm|yarn|pytest|cargo|go test|FAIL|PASS|exit code)\b/i.test(text)) return "shell_output";
18
+ const lines = text.split("\n");
19
+ const logHits = lines.filter((line) => /\d{4}-\d{2}-\d{2}|^\[[^\]]+\]\s+(ERROR|WARN|INFO|DEBUG|TRACE|FATAL)\b|\b(ERROR|WARN|FATAL)\b/i.test(line)).length;
20
+ const requiredHits = Math.max(1, Math.ceil(lines.length * 0.2));
21
+ const numberedOutputHits = lines.filter((line) => /^line \d+\b/i.test(line)).length;
22
+ if (logHits >= 1 && numberedOutputHits >= Math.ceil(lines.length * 0.5)) return "log";
23
+ if (logHits >= requiredHits) return "log";
24
+ return "plain_text";
25
+ }
@@ -0,0 +1,48 @@
1
+ import { classifyContent } from "./content-classifier.ts";
2
+ import { compressJsonText } from "./json-compressor.ts";
3
+ import { compressLog } from "./log-compressor.ts";
4
+ import { compressOperationalBlocks } from "./operational-block-compressor.ts";
5
+ import { compressStacktrace } from "./stacktrace-compressor.ts";
6
+ import { redactSecrets } from "../security/secret-redactor.ts";
7
+ import { RetrievalStore } from "../store/retrieval-store.ts";
8
+ import { byteLength } from "../utils/text.ts";
9
+
10
+ export type ContextCompression = {
11
+ text: string;
12
+ compressed: boolean;
13
+ kind: string;
14
+ retrievalId?: string;
15
+ compressorName?: string;
16
+ redactedSecrets: number;
17
+ };
18
+
19
+ // Only structured/operational content is safe to reduce. Prose, markdown,
20
+ // source code, and diffs pass through untouched so the model never loses
21
+ // meaning it needs (compression is opt-in and must stay non-destructive).
22
+ const COMPRESSIBLE = new Set(["json", "stacktrace", "log", "shell_output"]);
23
+
24
+ export async function compressContext(text: string, store: RetrievalStore, requestId?: string): Promise<ContextCompression> {
25
+ const redacted = redactSecrets(text);
26
+ const safeText = redacted.text;
27
+ const kind = classifyContent(safeText);
28
+ const id = store.idFor(safeText);
29
+ if (!COMPRESSIBLE.has(kind) || safeText.length < 2000) {
30
+ const embedded = safeText.length >= 2000 ? compressOperationalBlocks(safeText, id) : { text: safeText, compressed: false, kind: undefined, compressorName: undefined };
31
+ if (!embedded.compressed || byteLength(embedded.text) >= byteLength(safeText)) return { text: safeText, compressed: false, kind, redactedSecrets: redacted.redactions.length };
32
+ await store.save(safeText, { contentKind: embedded.kind ?? kind, compressedBytes: byteLength(embedded.text), compressorName: embedded.compressorName ?? "embedded", redacted: true, requestId });
33
+ return { text: embedded.text, compressed: true, kind, retrievalId: id, compressorName: embedded.compressorName, redactedSecrets: redacted.redactions.length };
34
+ }
35
+ const result = runCompressor(kind, safeText, id);
36
+ // Never claim compression that did not actually shrink the payload — otherwise
37
+ // we would send a larger body and report negative/zero savings dishonestly.
38
+ // Only persist the original once compression is confirmed beneficial.
39
+ if (!result.compressed || byteLength(result.text) >= byteLength(safeText)) return { text: safeText, compressed: false, kind, redactedSecrets: redacted.redactions.length };
40
+ await store.save(safeText, { contentKind: kind, compressedBytes: byteLength(result.text), compressorName: kind, redacted: true, requestId });
41
+ return { text: result.text, compressed: true, kind, retrievalId: id, compressorName: result.compressorName, redactedSecrets: redacted.redactions.length };
42
+ }
43
+
44
+ function runCompressor(kind: string, text: string, id: string) {
45
+ if (kind === "json") return compressJsonText(text, id);
46
+ if (kind === "stacktrace") return compressStacktrace(text, id);
47
+ return compressLog(text, id);
48
+ }
@@ -0,0 +1,54 @@
1
+ import { truncateValue } from "../utils/text.ts";
2
+
3
+ export type JsonCompressionResult = { text: string; compressed: boolean; compressorName: string };
4
+
5
+ const MAX_ARRAY_KEYS = 100;
6
+
7
+ export function compressJsonText(input: string, retrieveId: string): JsonCompressionResult {
8
+ let parsed: unknown;
9
+ try {
10
+ parsed = JSON.parse(input);
11
+ } catch {
12
+ return { text: input, compressed: false, compressorName: "json" };
13
+ }
14
+ if (Array.isArray(parsed) && parsed.length > 40) {
15
+ const first = parsed.slice(0, 20).map((item) => truncateValue(item));
16
+ const last = parsed.slice(-20).map((item) => truncateValue(item));
17
+ const keys = arrayKeys(parsed);
18
+ const text = [
19
+ `[molenkopf compressed: kind=json original_items=${parsed.length} kept_items=40 omitted_items=${parsed.length - 40} retrieve=${retrieveId}]`,
20
+ `keys: ${keys.items.join(", ")}${keys.omitted ? `, ... omitted_key_entries=${keys.omitted}` : ""}`,
21
+ "first:",
22
+ JSON.stringify(first, null, 2),
23
+ "last:",
24
+ JSON.stringify(last, null, 2)
25
+ ].join("\n");
26
+ return { text, compressed: true, compressorName: "json" };
27
+ }
28
+ if (input.length < 2000) return { text: input, compressed: false, compressorName: "json" };
29
+ const summary = truncateValue(parsed, 320);
30
+ return {
31
+ text: `[molenkopf compressed: kind=json retrieve=${retrieveId}]\n${JSON.stringify(summary, null, 2)}`,
32
+ compressed: true,
33
+ compressorName: "json"
34
+ };
35
+ }
36
+
37
+ function arrayKeys(items: unknown[]): { items: string[]; omitted: number } {
38
+ const seen = new Set<string>();
39
+ const out: string[] = [];
40
+ let omitted = 0;
41
+ for (const item of items) {
42
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
43
+ for (const key of Object.keys(item)) {
44
+ if (seen.has(key)) continue;
45
+ if (out.length >= MAX_ARRAY_KEYS) {
46
+ omitted++;
47
+ continue;
48
+ }
49
+ seen.add(key);
50
+ out.push(key);
51
+ }
52
+ }
53
+ return { items: out, omitted };
54
+ }
@@ -0,0 +1,32 @@
1
+ import { stripAnsi } from "../utils/text.ts";
2
+
3
+ export type CompressionResult = { text: string; compressed: boolean; compressorName: string };
4
+
5
+ const errorPattern = /ERROR|FATAL|FAIL|failed|exception|traceback|exit code|\.([jt]s|py|go|rs):\d+/i;
6
+
7
+ export function compressLog(input: string, retrieveId: string): CompressionResult {
8
+ const clean = stripAnsi(input);
9
+ const lines = clean.split(/\r?\n/);
10
+ if (lines.length <= 220 && clean.length < 12000) {
11
+ return { text: clean, compressed: false, compressorName: "log" };
12
+ }
13
+ const keep = new Set<number>();
14
+ for (let i = 0; i < Math.min(80, lines.length); i++) keep.add(i);
15
+ for (let i = Math.max(0, lines.length - 120); i < lines.length; i++) keep.add(i);
16
+ lines.forEach((line, index) => {
17
+ if (!errorPattern.test(line)) return;
18
+ for (let i = Math.max(0, index - 5); i <= Math.min(lines.length - 1, index + 5); i++) keep.add(i);
19
+ });
20
+ const sorted = [...keep].sort((a, b) => a - b);
21
+ const output = [`[molenkopf compressed: kind=log original_lines=${lines.length} kept_lines=${sorted.length} retrieve=${retrieveId}]`];
22
+ let previous = -1;
23
+ for (const index of sorted) {
24
+ if (index > previous + 1) {
25
+ output.push(`[molenkopf omitted: ${index - previous - 1} repetitive lines retrieve=${retrieveId}]`);
26
+ }
27
+ const line = lines[index];
28
+ if (line !== output[output.length - 1]) output.push(line);
29
+ previous = index;
30
+ }
31
+ return { text: output.join("\n"), compressed: true, compressorName: "log" };
32
+ }
@@ -0,0 +1,43 @@
1
+ import { classifyContent } from "./content-classifier.ts";
2
+ import { compressJsonText } from "./json-compressor.ts";
3
+ import { compressLog } from "./log-compressor.ts";
4
+ import { compressStacktrace } from "./stacktrace-compressor.ts";
5
+ import { byteLength } from "../utils/text.ts";
6
+
7
+ export type OperationalBlockCompression = {
8
+ text: string;
9
+ compressed: boolean;
10
+ kind?: string;
11
+ compressorName?: string;
12
+ };
13
+
14
+ const COMPRESSIBLE = new Set(["json", "stacktrace", "log", "shell_output"]);
15
+ const SOURCE_LANGS = new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "go", "rs", "java", "rb", "sql", "diff", "patch", "md", "markdown", "sh", "bash", "zsh", "fish", "c", "cc", "cpp", "cxx", "h", "hpp", "cs", "php", "swift", "kt", "kts", "html", "css"]);
16
+ const OPERATIONAL_LANGS = new Set(["log", "logs", "output", "shell-output", "terminal"]);
17
+ const FENCE = /```([a-z0-9_-]*)[^\n]*\n([\s\S]*?)```/gi;
18
+
19
+ export function compressOperationalBlocks(text: string, retrieveId: string): OperationalBlockCompression {
20
+ let compressed = false;
21
+ let kind: string | undefined;
22
+ let compressorName: string | undefined;
23
+ const rewritten = text.replace(FENCE, (match, lang: string, body: string) => {
24
+ const normalizedLang = lang.toLowerCase();
25
+ if (SOURCE_LANGS.has(normalizedLang) || body.length < 2000) return match;
26
+ if (normalizedLang && !OPERATIONAL_LANGS.has(normalizedLang)) return match;
27
+ const blockKind = classifyContent(body);
28
+ if (!COMPRESSIBLE.has(blockKind)) return match;
29
+ const result = compressStructured(blockKind, body, retrieveId);
30
+ if (!result.compressed || byteLength(result.text) >= byteLength(body)) return match;
31
+ compressed = true;
32
+ kind = blockKind;
33
+ compressorName = `embedded-${result.compressorName}`;
34
+ return "```" + lang + "\n" + result.text + "\n```";
35
+ });
36
+ return { text: rewritten, compressed, kind, compressorName };
37
+ }
38
+
39
+ function compressStructured(kind: string, text: string, retrieveId: string) {
40
+ if (kind === "json") return compressJsonText(text, retrieveId);
41
+ if (kind === "stacktrace") return compressStacktrace(text, retrieveId);
42
+ return compressLog(text, retrieveId);
43
+ }
@@ -0,0 +1,23 @@
1
+ export type StacktraceCompressionResult = { text: string; compressed: boolean; compressorName: string };
2
+
3
+ export function compressStacktrace(input: string, retrieveId: string): StacktraceCompressionResult {
4
+ const lines = input.split(/\r?\n/);
5
+ if (lines.length <= 12 && !/node_modules|node:internal|site-packages/.test(input)) {
6
+ return { text: input, compressed: false, compressorName: "stacktrace" };
7
+ }
8
+ const output = [`[molenkopf compressed: kind=stacktrace original_lines=${lines.length} retrieve=${retrieveId}]`];
9
+ let vendor = 0;
10
+ for (const line of lines) {
11
+ if (/node_modules|node:internal|\/vendor\/|site-packages|stdlib/.test(line)) {
12
+ vendor++;
13
+ continue;
14
+ }
15
+ if (vendor) {
16
+ output.push(`[molenkopf omitted: ${vendor} vendor/stdlib frames retrieve=${retrieveId}]`);
17
+ vendor = 0;
18
+ }
19
+ output.push(line);
20
+ }
21
+ if (vendor) output.push(`[molenkopf omitted: ${vendor} vendor/stdlib frames retrieve=${retrieveId}]`);
22
+ return { text: output.join("\n"), compressed: output.length < lines.length + 1, compressorName: "stacktrace" };
23
+ }
@@ -0,0 +1,146 @@
1
+ import { builtinPluginDescriptors } from "../plugins/plugin-descriptor.ts";
2
+
3
+ export type ProfilePolicy = { id: string; providerId: string; allowedModels?: string[]; defaultModel?: string };
4
+ export type PluginPolicy = { id: string; enabledPluginIds?: string[] };
5
+ export type ResolvedAgent = {
6
+ id: string;
7
+ providerId: string;
8
+ enabled?: boolean;
9
+ profileId?: string;
10
+ pluginPolicyId?: string;
11
+ scopes?: string[];
12
+ allowedModels?: string[];
13
+ defaultModel?: string;
14
+ enabledPluginIds?: string[];
15
+ };
16
+
17
+ type JsonRecord = Record<string, unknown>;
18
+
19
+ const ID_RE = /^[a-z0-9][a-z0-9._:-]{0,63}$/i;
20
+
21
+ export function normalizePolicyConfig(root: JsonRecord, providerIds: Set<string>, enabledProviderIds: Set<string>) {
22
+ const profiles = normalizeProfiles(root.profiles, providerIds);
23
+ const pluginPolicies = normalizePluginPolicies(root.pluginPolicies);
24
+ return {
25
+ profiles: profiles.items,
26
+ pluginPolicies: pluginPolicies.items,
27
+ agents: normalizeAgents(root.agents, profiles, pluginPolicies, providerIds, enabledProviderIds),
28
+ firstProvider: profiles.items[0]?.providerId
29
+ };
30
+ }
31
+
32
+ function normalizeProfiles(input: unknown, providerIds: Set<string>): { items: ProfilePolicy[]; byId: Map<string, ProfilePolicy> } {
33
+ if (input === undefined) return { items: [], byId: new Map() };
34
+ const items = array(input, "$.profiles").map((item, index) => {
35
+ const profile = record(item, `$.profiles[${index}]`);
36
+ const id = idValue(profile.id, `$.profiles[${index}].id`);
37
+ const providerId = idValue(profile.providerId, `$.profiles[${index}].providerId`);
38
+ if (!providerIds.has(providerId)) throw new Error(`unknown provider in profile: ${providerId}`);
39
+ const allowedModels = uniqueStrings(profile.allowedModels, `$.profiles[${index}].allowedModels`);
40
+ const defaultModel = stringOrUndefined(profile.defaultModel, `$.profiles[${index}].defaultModel`);
41
+ if (defaultModel && allowedModels && !allowedModels.includes(defaultModel)) throw new Error(`default model not allowed: ${id}`);
42
+ return clean({ id, providerId, allowedModels, defaultModel });
43
+ });
44
+ assertUnique(items.map((item) => item.id), "profile");
45
+ return { items, byId: new Map(items.map((item) => [item.id, item])) };
46
+ }
47
+
48
+ function normalizePluginPolicies(input: unknown): { items: PluginPolicy[]; byId: Map<string, PluginPolicy> } {
49
+ if (input === undefined) return { items: [], byId: new Map() };
50
+ const builtinPluginIds = new Set(builtinPluginDescriptors.map((plugin) => plugin.id));
51
+ const items = array(input, "$.pluginPolicies").map((item, index) => {
52
+ const policy = record(item, `$.pluginPolicies[${index}]`);
53
+ const id = idValue(policy.id, `$.pluginPolicies[${index}].id`);
54
+ if (policy.remotePlugins === true) throw new Error(`remote plugins unsupported: ${id}`);
55
+ const enabledPluginIds = uniqueStrings(policy.enabledPluginIds, `$.pluginPolicies[${index}].enabledPluginIds`);
56
+ enabledPluginIds?.forEach((pluginId) => {
57
+ if (!builtinPluginIds.has(pluginId)) throw new Error(`unknown enabled plugin id: ${pluginId}`);
58
+ });
59
+ return clean({ id, enabledPluginIds });
60
+ });
61
+ assertUnique(items.map((item) => item.id), "plugin policy");
62
+ return { items, byId: new Map(items.map((item) => [item.id, item])) };
63
+ }
64
+
65
+ function normalizeAgents(input: unknown, profiles: ReturnType<typeof normalizeProfiles>, policies: ReturnType<typeof normalizePluginPolicies>, providerIds: Set<string>, enabledProviderIds: Set<string>): ResolvedAgent[] {
66
+ if (input === undefined) return [];
67
+ const agents = array(input, "$.agents").map((item, index) => {
68
+ const agent = record(item, `$.agents[${index}]`);
69
+ const id = idValue(agent.id, `$.agents[${index}].id`);
70
+ const enabled = agent.enabled === undefined ? true : boolean(agent.enabled, `$.agents[${index}].enabled`);
71
+ const profileId = optionalId(agent.profileId, `$.agents[${index}].profileId`);
72
+ const pluginPolicyId = optionalId(agent.pluginPolicyId, `$.agents[${index}].pluginPolicyId`);
73
+ const profile = profileId ? profiles.byId.get(profileId) : undefined;
74
+ const policy = pluginPolicyId ? policies.byId.get(pluginPolicyId) : undefined;
75
+ if (profileId && !profile) throw new Error(`unknown profile in agent: ${profileId}`);
76
+ if (pluginPolicyId && !policy) throw new Error(`unknown plugin policy in agent: ${pluginPolicyId}`);
77
+ if (agent.providerId !== undefined && profileId) throw new Error(`conflicting provider/profile in agent: ${id}`);
78
+ const providerId = agent.providerId !== undefined ? idValue(agent.providerId, `$.agents[${index}].providerId`) : profile?.providerId;
79
+ if (!providerId) throw new Error(`agent requires provider or profile: ${id}`);
80
+ if (!providerIds.has(providerId)) throw new Error(`unknown provider in agent: ${providerId}`);
81
+ if (enabled && !enabledProviderIds.has(providerId)) throw new Error(`agent provider disabled: ${providerId}`);
82
+ return clean({ id, providerId, enabled, profileId, pluginPolicyId, scopes: nonEmptyUniqueIds(agent.scopes, `$.agents[${index}].scopes`), allowedModels: profile?.allowedModels, defaultModel: profile?.defaultModel, enabledPluginIds: policy?.enabledPluginIds });
83
+ });
84
+ assertUnique(agents.map((item) => item.id), "agent");
85
+ return agents;
86
+ }
87
+
88
+ function record(value: unknown, path: string): JsonRecord {
89
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`expected object: ${path}`);
90
+ return value as JsonRecord;
91
+ }
92
+
93
+ function array(value: unknown, path: string): unknown[] {
94
+ if (!Array.isArray(value)) throw new Error(`expected array: ${path}`);
95
+ return value;
96
+ }
97
+
98
+ function optionalId(value: unknown, path: string): string | undefined {
99
+ return value === undefined ? undefined : idValue(value, path);
100
+ }
101
+
102
+ function idValue(value: unknown, path: string): string {
103
+ const id = string(value, path);
104
+ if (!ID_RE.test(id)) throw new Error(`invalid id: ${path}`);
105
+ return id;
106
+ }
107
+
108
+ function uniqueStrings(value: unknown, path: string): string[] | undefined {
109
+ if (value === undefined) return undefined;
110
+ const values = array(value, path).map((item, index) => string(item, `${path}[${index}]`));
111
+ return assertUnique(values, path), values;
112
+ }
113
+
114
+ function nonEmptyUniqueIds(value: unknown, path: string): string[] | undefined {
115
+ const values = uniqueStrings(value, path);
116
+ if (values === undefined) return undefined;
117
+ if (!values.length) throw new Error(`empty string array: ${path}`);
118
+ values.forEach((item, index) => idValue(item, `${path}[${index}]`));
119
+ return values;
120
+ }
121
+
122
+ function stringOrUndefined(value: unknown, path: string): string | undefined {
123
+ return value === undefined ? undefined : string(value, path);
124
+ }
125
+
126
+ function string(value: unknown, path: string): string {
127
+ if (typeof value !== "string" || !value.trim()) throw new Error(`expected string: ${path}`);
128
+ return value.trim();
129
+ }
130
+
131
+ function boolean(value: unknown, path: string): boolean {
132
+ if (typeof value !== "boolean") throw new Error(`expected boolean: ${path}`);
133
+ return value;
134
+ }
135
+
136
+ function assertUnique(values: string[], label: string): void {
137
+ const seen = new Set<string>();
138
+ values.forEach((value) => {
139
+ if (seen.has(value)) throw new Error(`duplicate ${label} id: ${value}`);
140
+ seen.add(value);
141
+ });
142
+ }
143
+
144
+ function clean<T extends Record<string, unknown>>(value: T): T {
145
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T;
146
+ }
@@ -0,0 +1,137 @@
1
+ import type { ProviderConfig } from "../providers/provider-catalog.ts";
2
+ import { normalizeProvider } from "./provider-config.ts";
3
+ import { validateProviderTarget } from "../security/target-policy.ts";
4
+ import { normalizePolicyConfig, type PluginPolicy, type ProfilePolicy, type ResolvedAgent } from "./config-policies.ts";
5
+
6
+ export type NormalizedMolenkopfConfig = {
7
+ target: string;
8
+ server: { bindHost?: string; port?: number; allowPublicBind?: boolean; dataDir?: string };
9
+ providers: ProviderConfig[];
10
+ activeProviderId: string;
11
+ profiles: ProfilePolicy[];
12
+ pluginPolicies: PluginPolicy[];
13
+ agents: ResolvedAgent[];
14
+ };
15
+
16
+ type JsonRecord = Record<string, unknown>;
17
+
18
+ const FORBIDDEN_KEYS = new Set([
19
+ "apikey", "xapikey", "authorization", "cookie", "password", "credential", "credentials",
20
+ "credentialenv", "accesscredential", "clientcredentials", "token", "accesstoken",
21
+ "refreshtoken", "bearertoken", "sessiontoken", "secret", "clientsecret"
22
+ ]);
23
+ const SAFE_ACCOUNTING_KEYS = new Set([
24
+ "tokenlimit", "tokensperday", "inputtokens", "outputtokens",
25
+ "estimatedoriginaltokens", "estimatedcompressedtokens", "estimatedsavedtokens"
26
+ ]);
27
+
28
+ export function parseMolenkopfConfigJson(text: string, source = "molenkopf.config.json"): NormalizedMolenkopfConfig {
29
+ let parsed: unknown;
30
+ try {
31
+ parsed = JSON.parse(text);
32
+ } catch {
33
+ throw new Error(`invalid Molenkopf config JSON: ${source}`);
34
+ }
35
+ return normalizeMolenkopfConfig(parsed);
36
+ }
37
+
38
+ export function normalizeMolenkopfConfig(input: unknown): NormalizedMolenkopfConfig {
39
+ assertNoForbiddenKeys(input);
40
+ const root = record(input, "$");
41
+ const schemaVersion = root.schemaVersion ?? root.version;
42
+ if (schemaVersion !== 1) throw new Error("molenkopf config schemaVersion must be 1");
43
+ const providers = array(root.providers, "$.providers").map(normalizeProvider);
44
+ if (!providers.length) throw new Error("molenkopf config requires providers");
45
+ assertUnique(providers.map((item) => item.id), "provider");
46
+ const providerIds = new Set(providers.map((item) => item.id));
47
+ const enabledProviderIds = new Set(providers.filter((item) => item.enabled !== false).map((item) => item.id));
48
+ const policies = normalizePolicyConfig(root, providerIds, enabledProviderIds);
49
+ const server = normalizeServer(root.server);
50
+ const target = stringOrUndefined(root.target) ?? providers[0].target;
51
+ validateTarget(target, "$.target", providers);
52
+ const activeProviderId = policies.firstProvider ?? providers.find((item) => item.enabled !== false)?.id ?? providers[0].id;
53
+ if (!providerIds.has(activeProviderId)) throw new Error(`unknown active provider: ${activeProviderId}`);
54
+ if (providers.find((item) => item.id === activeProviderId)?.enabled === false) throw new Error(`active provider disabled: ${activeProviderId}`);
55
+ return { target, server, providers, activeProviderId, profiles: policies.profiles, pluginPolicies: policies.pluginPolicies, agents: policies.agents };
56
+ }
57
+
58
+ function normalizeServer(input: unknown): NormalizedMolenkopfConfig["server"] {
59
+ if (input === undefined) return {};
60
+ const item = record(input, "$.server");
61
+ const out: NormalizedMolenkopfConfig["server"] = {};
62
+ if (item.bindHost !== undefined) out.bindHost = string(item.bindHost, "$.server.bindHost");
63
+ if (item.host !== undefined) out.bindHost = string(item.host, "$.server.host");
64
+ if (item.port !== undefined) out.port = port(item.port);
65
+ if (item.allowPublicBind !== undefined) out.allowPublicBind = boolean(item.allowPublicBind, "$.server.allowPublicBind");
66
+ if (item.dataDir !== undefined) out.dataDir = string(item.dataDir, "$.server.dataDir");
67
+ return out;
68
+ }
69
+
70
+ function assertNoForbiddenKeys(value: unknown, path = "$") {
71
+ if (!value || typeof value !== "object") return;
72
+ if (Array.isArray(value)) return value.forEach((item, index) => assertNoForbiddenKeys(item, `${path}[${index}]`));
73
+ Object.entries(value as JsonRecord).forEach(([key, child]) => {
74
+ const normalized = key.toLowerCase().replace(/[-_]/g, "");
75
+ if (isForbiddenSecretKey(normalized) && !isAllowedCredentialRef(path, normalized)) throw new Error(`forbidden secret field in config: ${path}.${key}`);
76
+ assertNoForbiddenKeys(child, `${path}.${key}`);
77
+ });
78
+ }
79
+
80
+ function isForbiddenSecretKey(normalized: string): boolean {
81
+ if (SAFE_ACCOUNTING_KEYS.has(normalized)) return false;
82
+ return FORBIDDEN_KEYS.has(normalized)
83
+ || normalized.endsWith("apikey")
84
+ || normalized.endsWith("token")
85
+ || normalized.endsWith("secret")
86
+ || normalized.endsWith("credential")
87
+ || normalized.endsWith("credentials")
88
+ || normalized.endsWith("password");
89
+ }
90
+
91
+ function isAllowedCredentialRef(path: string, normalized: string): boolean {
92
+ return normalized === "credentialref" && /^\$\.providers\[\d+\]\.auth$/.test(path);
93
+ }
94
+
95
+ function validateTarget(value: string, path: string, providers: ProviderConfig[]) {
96
+ const url = new URL(value);
97
+ if (url.protocol === "cli:") return;
98
+ validateProviderTarget(value, { path, allowPrivate: providers.some((item) => item.kind === "local" && item.target === value) });
99
+ }
100
+
101
+ function assertUnique(values: string[], label: string) {
102
+ const seen = new Set<string>();
103
+ values.forEach((value) => {
104
+ if (seen.has(value)) throw new Error(`duplicate ${label} id: ${value}`);
105
+ seen.add(value);
106
+ });
107
+ }
108
+
109
+ function record(value: unknown, path: string): JsonRecord {
110
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`expected object: ${path}`);
111
+ return value as JsonRecord;
112
+ }
113
+
114
+ function array(value: unknown, path: string): unknown[] {
115
+ if (!Array.isArray(value)) throw new Error(`expected array: ${path}`);
116
+ return value;
117
+ }
118
+
119
+ function string(value: unknown, path: string): string {
120
+ if (typeof value !== "string" || !value.trim()) throw new Error(`expected string: ${path}`);
121
+ return value.trim();
122
+ }
123
+
124
+ function stringOrUndefined(value: unknown): string | undefined {
125
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
126
+ }
127
+
128
+ function boolean(value: unknown, path: string): boolean {
129
+ if (typeof value !== "boolean") throw new Error(`expected boolean: ${path}`);
130
+ return value;
131
+ }
132
+
133
+ function port(value: unknown): number {
134
+ const portNumber = Number(value);
135
+ if (!Number.isInteger(portNumber) || portNumber < 0 || portNumber > 65535) throw new Error("invalid server port");
136
+ return portNumber;
137
+ }