@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,102 @@
1
+ import { isBudget } from "./budget.ts";
2
+ import { IDENTITY_SCHEMA_VERSION, type ApiKey, type IdentityData, type Team, type User } from "./types.ts";
3
+ import type { PriceTable } from "./pricing.ts";
4
+
5
+ export type IdentityRow = { id: string; json: string };
6
+ export type IdentityMetaRow = { k: string; json: string };
7
+
8
+ export function parseIdentityRow<T extends { id: string }>(row: IdentityRow, check: (value: unknown) => value is T, label: string): T {
9
+ const value = parseJson(row.json, label);
10
+ if (!check(value)) throw new Error(`invalid ${label} row`);
11
+ if (value.id !== row.id) throw new Error(`${label} id mismatch`);
12
+ return value;
13
+ }
14
+
15
+ export function loadIdentityMeta(data: IdentityData, row: IdentityMetaRow): void {
16
+ const value = parseJson(row.json, `meta ${row.k}`);
17
+ if (row.k === "orgBudget" && isBudget(value)) data.orgBudget = value;
18
+ else if (row.k === "pricing" && isPriceTable(value)) data.pricing = value;
19
+ else throw new Error(`invalid meta row ${row.k}`);
20
+ }
21
+
22
+ export function validateIdentityReferences(data: IdentityData): void {
23
+ for (const user of Object.values(data.users)) for (const id of user.teamIds) if (!data.teams[id]) throw new Error(`user ${user.id} references missing team`);
24
+ for (const team of Object.values(data.teams)) for (const id of team.managerIds) if (!data.users[id]) throw new Error(`team ${team.id} references missing manager`);
25
+ for (const key of Object.values(data.keys)) {
26
+ if (!data.users[key.ownerUserId]) throw new Error(`key ${key.id} references missing owner`);
27
+ if (key.teamId && !data.teams[key.teamId]) throw new Error(`key ${key.id} references missing team`);
28
+ }
29
+ }
30
+
31
+ export function validateIdentityData(data: IdentityData): void {
32
+ if (data.schemaVersion !== IDENTITY_SCHEMA_VERSION) throw new Error("invalid identity schema version");
33
+ validateRows(data.users, isIdentityUser, "user");
34
+ validateRows(data.teams, isIdentityTeam, "team");
35
+ validateRows(data.keys, isIdentityApiKey, "api_key");
36
+ if (data.orgBudget !== undefined && !isBudget(data.orgBudget)) throw new Error("invalid orgBudget row");
37
+ if (data.pricing !== undefined && !isPriceTable(data.pricing)) throw new Error("invalid pricing row");
38
+ validateIdentityReferences(data);
39
+ }
40
+
41
+ export function isIdentityUser(value: unknown): value is User {
42
+ const user = value as User;
43
+ return isObject(user) && isUserId(user.id) && typeof user.displayName === "string" && ["admin", "manager", "member"].includes(user.role)
44
+ && stringArray(user.teamIds) && typeof user.createdAt === "string" && (user.password === undefined || isPassword(user.password))
45
+ && (user.loginDisabled === undefined || typeof user.loginDisabled === "boolean") && (user.budget === undefined || isBudget(user.budget))
46
+ && (user.sessionVersion === undefined || (Number.isSafeInteger(user.sessionVersion) && user.sessionVersion >= 0));
47
+ }
48
+
49
+ export function isIdentityTeam(value: unknown): value is Team {
50
+ const team = value as Team;
51
+ return isObject(team) && isSlugId(team.id) && typeof team.name === "string" && (team.allowedProviders === "*" || stringArray(team.allowedProviders))
52
+ && stringArray(team.managerIds) && typeof team.createdAt === "string" && (team.budget === undefined || isBudget(team.budget));
53
+ }
54
+
55
+ export function isIdentityApiKey(value: unknown): value is ApiKey {
56
+ const key = value as ApiKey;
57
+ return isObject(key) && isSlugId(key.id) && typeof key.hash === "string" && typeof key.prefix === "string" && isUserId(key.ownerUserId)
58
+ && typeof key.createdAt === "string" && (key.teamId === undefined || isSlugId(key.teamId)) && (key.scopes === undefined || stringArray(key.scopes))
59
+ && (key.budget === undefined || isBudget(key.budget));
60
+ }
61
+
62
+ function parseJson(json: string, label: string): unknown {
63
+ try { return JSON.parse(json) as unknown; } catch { throw new Error(`invalid ${label} json`); }
64
+ }
65
+
66
+ function isPriceTable(value: unknown): value is PriceTable {
67
+ if (!isObject(value)) return false;
68
+ return Object.values(value).every((entry) => isObject(entry) && nonNegativeNumber(entry.inPerMTok) && nonNegativeNumber(entry.outPerMTok));
69
+ }
70
+
71
+ function isPassword(value: unknown): boolean {
72
+ const password = value as { salt?: unknown; hash?: unknown };
73
+ return isObject(password) && typeof password.salt === "string" && typeof password.hash === "string";
74
+ }
75
+
76
+ function isObject(value: unknown): value is Record<string, unknown> {
77
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
78
+ }
79
+
80
+ function validateRows<T extends { id: string }>(rows: Record<string, T>, check: (value: unknown) => value is T, label: string): void {
81
+ for (const [id, value] of Object.entries(rows)) {
82
+ if (!check(value)) throw new Error(`invalid ${label} row`);
83
+ if (value.id !== id) throw new Error(`${label} id mismatch`);
84
+ }
85
+ }
86
+
87
+ function isUserId(value: unknown): value is string {
88
+ if (typeof value !== "string") return false;
89
+ return /^[a-z0-9][a-z0-9._:-]{0,63}$/i.test(value) || (value.length <= 254 && /^[a-z0-9._%+-]{1,64}@[a-z0-9.-]{1,190}\.[a-z]{2,24}$/i.test(value));
90
+ }
91
+
92
+ function isSlugId(value: unknown): value is string {
93
+ return typeof value === "string" && /^[a-z0-9][a-z0-9._:-]{0,63}$/i.test(value);
94
+ }
95
+
96
+ function stringArray(value: unknown): value is string[] {
97
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
98
+ }
99
+
100
+ function nonNegativeNumber(value: unknown): value is number {
101
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
102
+ }
@@ -0,0 +1,18 @@
1
+ import type { KeyPermissions, User } from "./types.ts";
2
+
3
+ export type ResolvedKeyPermissions = Required<KeyPermissions>;
4
+
5
+ export function resolveKeyPermissions(user: Pick<User, "keyPermissions"> | undefined): ResolvedKeyPermissions {
6
+ return {
7
+ create: user?.keyPermissions?.create !== false,
8
+ revoke: user?.keyPermissions?.revoke !== false
9
+ };
10
+ }
11
+
12
+ export function canCreateOwnKey(user: Pick<User, "keyPermissions"> | undefined): boolean {
13
+ return resolveKeyPermissions(user).create;
14
+ }
15
+
16
+ export function canRevokeOwnKey(user: Pick<User, "keyPermissions"> | undefined): boolean {
17
+ return resolveKeyPermissions(user).revoke;
18
+ }
@@ -0,0 +1,11 @@
1
+ // Local, manually-maintained price table → € cost. No network lookups (invariant).
2
+ // Prices are per 1,000,000 tokens, keyed by provider id; 0/absent means free.
3
+
4
+ export type ProviderPrice = { inPerMTok: number; outPerMTok: number };
5
+ export type PriceTable = Record<string, ProviderPrice>;
6
+
7
+ export function costEur(price: ProviderPrice | undefined, inputTokens: number, outputTokens: number): number {
8
+ if (!price) return 0;
9
+ const cost = (inputTokens / 1_000_000) * (price.inPerMTok || 0) + (outputTokens / 1_000_000) * (price.outPerMTok || 0);
10
+ return Math.round(cost * 1e6) / 1e6; // round to micro-euro to avoid float drift
11
+ }
@@ -0,0 +1,87 @@
1
+ import type { PasswordHash } from "../auth/password.ts";
2
+ import type { PriceTable } from "./pricing.ts";
3
+
4
+ // Identity model for the multi-user / team gateway (see
5
+ // docs/superpowers/plans/2026-06-21-molenkopf-multiuser-team-redesign.md).
6
+ // Persisted locally; provider credentials are NEVER stored here (RAM-only),
7
+ // and API-key secrets are stored as a hash only — never in plaintext.
8
+
9
+ export type Role = "admin" | "manager" | "member";
10
+ export type BudgetPeriod = "day" | "week" | "month" | "total";
11
+ export type BudgetAction = "block" | "warn";
12
+ export type KeyPermissions = { create?: boolean; revoke?: boolean };
13
+
14
+ export type Budget = {
15
+ tokenLimit?: number;
16
+ costLimitEur?: number;
17
+ period: BudgetPeriod;
18
+ onExceed: BudgetAction;
19
+ };
20
+
21
+ export type User = {
22
+ id: string;
23
+ displayName: string;
24
+ role: Role;
25
+ password?: PasswordHash;
26
+ loginDisabled?: boolean;
27
+ teamIds: string[];
28
+ keyPermissions?: KeyPermissions;
29
+ budget?: Budget;
30
+ disabled?: boolean;
31
+ sessionVersion?: number;
32
+ createdAt: string;
33
+ createdBy?: string;
34
+ };
35
+
36
+ export type Team = {
37
+ id: string;
38
+ name: string;
39
+ allowedProviders: "*" | string[];
40
+ managerIds: string[];
41
+ budget?: Budget;
42
+ createdAt: string;
43
+ };
44
+
45
+ export type ApiKey = {
46
+ id: string; // public, displayable id, e.g. "key_ab12cd"
47
+ hash: string; // sha256(secret) hex — the only stored form of the secret
48
+ prefix: string; // first chars of the secret for recognition, e.g. "mk_ab12"
49
+ ownerUserId: string;
50
+ teamId?: string; // optional billing team; fallback is owner team membership
51
+ agentLabel?: string; // optional logical agent name ("ci-bot")
52
+ project?: string; // optional project/workspace grouping for reporting
53
+ scopes?: string[]; // allowed provider ids; undefined = inherit owner/team allowance
54
+ budget?: Budget;
55
+ disabled?: boolean;
56
+ createdAt: string;
57
+ lastUsedAt?: string;
58
+ };
59
+
60
+ export type IdentityData = {
61
+ schemaVersion: number;
62
+ users: Record<string, User>;
63
+ teams: Record<string, Team>;
64
+ keys: Record<string, ApiKey>;
65
+ orgBudget?: Budget;
66
+ pricing?: PriceTable;
67
+ };
68
+
69
+ export const IDENTITY_SCHEMA_VERSION = 1;
70
+
71
+ export function emptyIdentity(): IdentityData {
72
+ return { schemaVersion: IDENTITY_SCHEMA_VERSION, users: {}, teams: {}, keys: {} };
73
+ }
74
+
75
+ // View types omit secrets/hashes for safe display.
76
+ export type UserView = Omit<User, "password"> & { hasPassword: boolean };
77
+ export type ApiKeyView = Omit<ApiKey, "hash"> & {};
78
+
79
+ export function viewUser(user: User): UserView {
80
+ const { password, ...rest } = user;
81
+ return { ...rest, hasPassword: Boolean(password?.hash) };
82
+ }
83
+
84
+ export function viewKey(key: ApiKey): ApiKeyView {
85
+ const { hash, ...rest } = key;
86
+ return { ...rest };
87
+ }
@@ -0,0 +1,116 @@
1
+ import { openDb, type Db } from "./db.ts";
2
+ import { defaultDataDir } from "../storage/local-paths.ts";
3
+
4
+ // Persistent live-usage snapshot, stored in the SQLite `usage` table so per-key/
5
+ // user/team/provider counters survive restarts. Audit remains the raw source of
6
+ // truth; this is the fast aggregate. Coalesced background flush.
7
+
8
+ export type UsageMaps = {
9
+ usageByAgent: Record<string, unknown>;
10
+ usageByUser: Record<string, unknown>;
11
+ usageByProvider: Record<string, unknown>;
12
+ usageByKey: Record<string, unknown>;
13
+ usageByTeam: Record<string, unknown>;
14
+ };
15
+
16
+ const FIELDS: (keyof UsageMaps)[] = ["usageByAgent", "usageByUser", "usageByProvider", "usageByKey", "usageByTeam"];
17
+
18
+ export class UsageSnapshotError extends Error {
19
+ constructor(message: string) {
20
+ super(message);
21
+ this.name = "UsageSnapshotError";
22
+ }
23
+ }
24
+
25
+ export class UsageSnapshotStore {
26
+ private root: string;
27
+ private db?: Db;
28
+ private flushing = false;
29
+ private pending?: UsageMaps;
30
+ private flushPromise?: Promise<void>;
31
+ private lastError?: unknown;
32
+ private closed = false;
33
+
34
+ constructor(root = defaultDataDir()) {
35
+ this.root = root;
36
+ }
37
+
38
+ private handle(): Db {
39
+ if (!this.db) this.db = openDb(this.root);
40
+ return this.db;
41
+ }
42
+
43
+ async load(): Promise<Partial<UsageMaps> | undefined> {
44
+ const db = this.handle();
45
+ const rows = db.prepare("SELECT id, json FROM usage WHERE scope = 'live'").all() as { id: string; json: string }[];
46
+ if (!rows.length) return undefined;
47
+ const out: Partial<UsageMaps> = {};
48
+ for (const row of rows) {
49
+ if ((FIELDS as string[]).includes(row.id)) {
50
+ try {
51
+ (out as Record<string, unknown>)[row.id] = JSON.parse(row.json);
52
+ } catch {
53
+ throw new UsageSnapshotError(`invalid usage snapshot row: ${row.id}`);
54
+ }
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+
60
+ async save(maps: UsageMaps): Promise<void> {
61
+ if (this.closed) throw new UsageSnapshotError("usage snapshot store closed");
62
+ const db = this.handle();
63
+ db.exec("BEGIN");
64
+ try {
65
+ const stmt = db.prepare("INSERT INTO usage(scope, id, json) VALUES('live', ?, ?) ON CONFLICT(scope, id) DO UPDATE SET json = excluded.json");
66
+ for (const field of FIELDS) stmt.run(field, JSON.stringify(maps[field] ?? {}));
67
+ db.exec("COMMIT");
68
+ } catch (error) {
69
+ db.exec("ROLLBACK");
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ async flush(): Promise<void> {
75
+ if (this.lastError) { const error = this.lastError; this.lastError = undefined; throw error; }
76
+ if (this.closed) throw new UsageSnapshotError("usage snapshot store closed");
77
+ if (!this.flushPromise && this.pending) this.flushPromise = this.runFlush();
78
+ await this.flushPromise;
79
+ if (this.lastError) { const error = this.lastError; this.lastError = undefined; throw error; }
80
+ }
81
+
82
+ async close(): Promise<void> {
83
+ if (!this.closed) await this.flush();
84
+ this.closed = true;
85
+ try { this.db?.close(); } catch { /* ignore */ }
86
+ this.db = undefined;
87
+ }
88
+
89
+ // Coalesced background flush: marks dirty and flushes at most one at a time.
90
+ schedule(maps: UsageMaps): void {
91
+ if (this.closed) return;
92
+ this.pending = maps;
93
+ if (!this.flushing) queueMicrotask(() => {
94
+ this.flushPromise ??= this.runFlush().catch((error) => { this.lastError = error; });
95
+ });
96
+ }
97
+
98
+ private async runFlush(): Promise<void> {
99
+ this.flushing = true;
100
+ try {
101
+ while (this.pending && !this.closed) {
102
+ const next = this.pending;
103
+ this.pending = undefined;
104
+ try {
105
+ await this.save(next);
106
+ } catch (error) {
107
+ this.pending = next;
108
+ throw error;
109
+ }
110
+ }
111
+ } finally {
112
+ this.flushing = false;
113
+ this.flushPromise = undefined;
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,74 @@
1
+ import type { AuditManifest } from "./audit-store.ts";
2
+ import { confirmedSavedTokens } from "./audit-metrics.ts";
3
+
4
+ export type AuditActivityGroup = {
5
+ id: string;
6
+ label: string;
7
+ clientId: string;
8
+ clientLabel: string;
9
+ keyId?: string;
10
+ project?: string;
11
+ providerId: string;
12
+ endpoint: string;
13
+ status: string;
14
+ requests: number;
15
+ errors: number;
16
+ unknown: number;
17
+ originalTokens: number;
18
+ forwardedTokens: number;
19
+ savedTokens: number;
20
+ compressedItems: number;
21
+ retrievalRefs: number;
22
+ latestAt?: string;
23
+ };
24
+
25
+ export function summarizeRecentActivity(manifests: AuditManifest[], limit = 8): AuditActivityGroup[] {
26
+ const groups = new Map<string, AuditActivityGroup>();
27
+ for (const manifest of manifests.slice(-50)) add(groups, manifest);
28
+ return [...groups.values()]
29
+ .sort((a, b) => (b.latestAt ?? "").localeCompare(a.latestAt ?? "") || b.requests - a.requests || a.id.localeCompare(b.id))
30
+ .slice(0, limit);
31
+ }
32
+
33
+ function add(groups: Map<string, AuditActivityGroup>, manifest: AuditManifest) {
34
+ const client: NonNullable<AuditManifest["client"]> = manifest.client ?? { id: "unattributed", label: "unattributed client", source: "unattributed" };
35
+ const providerId = manifest.providerId || manifest.targetHost || "unknown";
36
+ const endpoint = `${manifest.method} ${pathOnly(manifest.path)}`;
37
+ const status = statusGroup(manifest.statusCode);
38
+ const id = [client.id, client.keyId ?? "", client.project ?? "", providerId, endpoint, status].join("|");
39
+ const group: AuditActivityGroup = groups.get(id) ?? {
40
+ id, label: `${client.label} -> ${providerId}`, clientId: client.id, clientLabel: client.label, keyId: client.keyId, project: client.project,
41
+ providerId, endpoint, status, requests: 0, errors: 0, unknown: 0, originalTokens: 0, forwardedTokens: 0,
42
+ savedTokens: 0, compressedItems: 0, retrievalRefs: 0
43
+ };
44
+ group.requests++;
45
+ if (statusKind(manifest.statusCode) === "error") group.errors++;
46
+ if (statusKind(manifest.statusCode) === "unknown") group.unknown++;
47
+ group.originalTokens += manifest.estimatedOriginalTokens;
48
+ group.forwardedTokens += manifest.estimatedCompressedTokens;
49
+ group.savedTokens += confirmedSavedTokens(manifest);
50
+ group.compressedItems += manifest.compressedItems;
51
+ group.retrievalRefs += manifest.retrievalIds.length;
52
+ if (!group.latestAt || manifest.timestamp > group.latestAt) group.latestAt = manifest.timestamp;
53
+ groups.set(id, group);
54
+ }
55
+
56
+ function statusGroup(statusCode: number | undefined): string {
57
+ if (statusCode === undefined) return "unknown";
58
+ return `${Math.floor(statusCode / 100)}xx`;
59
+ }
60
+
61
+ function statusKind(statusCode: number | undefined): "ok" | "error" | "unknown" {
62
+ if (!Number.isInteger(statusCode)) return "unknown";
63
+ if (statusCode >= 200 && statusCode <= 399) return "ok";
64
+ if (statusCode >= 400 && statusCode <= 599) return "error";
65
+ return "unknown";
66
+ }
67
+
68
+ function pathOnly(path: string): string {
69
+ try {
70
+ return new URL(path, "http://molenkopf.local").pathname || "/";
71
+ } catch {
72
+ return "unknown";
73
+ }
74
+ }
@@ -0,0 +1,7 @@
1
+ import type { AuditManifest } from "./audit-store.ts";
2
+
3
+ export function confirmedSavedTokens(manifest: AuditManifest): number {
4
+ if (manifest.compressedItems <= 0) return 0;
5
+ if (manifest.estimatedSavedTokens > 0) return manifest.estimatedSavedTokens;
6
+ return Math.max(0, manifest.estimatedOriginalTokens - manifest.estimatedCompressedTokens);
7
+ }
@@ -0,0 +1,113 @@
1
+ import { redactSecrets } from "../security/secret-redactor.ts";
2
+ import type { AuditManifest } from "./audit-store.ts";
3
+
4
+ export function normalizedManifest(manifest: AuditManifest): AuditManifest {
5
+ if (!isAuditManifest(manifest)) throw new Error("invalid audit manifest");
6
+ const safe: AuditManifest = {
7
+ requestId: safeName(redactSecrets(manifest.requestId).text),
8
+ timestamp: Number.isNaN(Date.parse(manifest.timestamp)) ? new Date().toISOString() : manifest.timestamp,
9
+ method: manifest.method.replace(/[^A-Z]/gi, "").toUpperCase().slice(0, 12) || "GET",
10
+ path: safePath(manifest.path),
11
+ targetHost: safeToken(redactSecrets(manifest.targetHost).text, "target"),
12
+ compressedItems: finiteNumber(manifest.compressedItems),
13
+ estimatedOriginalTokens: finiteNumber(manifest.estimatedOriginalTokens),
14
+ estimatedCompressedTokens: finiteNumber(manifest.estimatedCompressedTokens),
15
+ estimatedSavedTokens: finiteNumber(manifest.estimatedSavedTokens),
16
+ redactedSecrets: finiteNumber(manifest.redactedSecrets),
17
+ retrievalIds: manifest.retrievalIds.map(safeRetrievalId).filter((item): item is string => Boolean(item)).slice(0, 50),
18
+ compressorsUsed: manifest.compressorsUsed.map((item) => redactedToken(String(item), "compressor")).slice(0, 20),
19
+ warnings: manifest.warnings.map((item) => safeWarning(redactSecrets(String(item)).text)).slice(0, 20)
20
+ };
21
+ const statusCode = finiteOptional(manifest.statusCode);
22
+ const durationMs = finiteOptional(manifest.durationMs);
23
+ const upstreamInputTokens = finiteOptional(manifest.upstreamInputTokens);
24
+ const upstreamOutputTokens = finiteOptional(manifest.upstreamOutputTokens);
25
+
26
+ if (manifest.providerId) safe.providerId = redactedToken(manifest.providerId, "provider");
27
+ if (manifest.client) safe.client = safeClient(manifest.client);
28
+ if (statusCode !== undefined) safe.statusCode = statusCode;
29
+ if (durationMs !== undefined) safe.durationMs = durationMs;
30
+ if (upstreamInputTokens !== undefined) safe.upstreamInputTokens = upstreamInputTokens;
31
+ if (upstreamOutputTokens !== undefined) safe.upstreamOutputTokens = upstreamOutputTokens;
32
+ return safe;
33
+ }
34
+
35
+ export function isAuditManifest(value: unknown): value is AuditManifest {
36
+ const item = value as AuditManifest;
37
+ return Boolean(item && typeof item === "object" && typeof item.requestId === "string" && typeof item.timestamp === "string"
38
+ && typeof item.method === "string" && typeof item.path === "string" && typeof item.targetHost === "string"
39
+ && typeof item.compressedItems === "number" && typeof item.estimatedOriginalTokens === "number"
40
+ && typeof item.estimatedCompressedTokens === "number" && typeof item.estimatedSavedTokens === "number"
41
+ && typeof item.redactedSecrets === "number" && finiteOptionalNumber(item.statusCode) && finiteOptionalNumber(item.durationMs)
42
+ && stringArray(item.retrievalIds) && stringArray(item.compressorsUsed) && stringArray(item.warnings));
43
+ }
44
+
45
+ function safeClient(client: NonNullable<AuditManifest["client"]>): NonNullable<AuditManifest["client"]> {
46
+ const source = safeSource(client.source);
47
+ const safe: NonNullable<AuditManifest["client"]> = {
48
+ source,
49
+ id: redactedToken(text(client.id, "client"), "client"),
50
+ label: redactedToken(text(client.label, source), source)
51
+ };
52
+ if (client.userId) safe.userId = redactedToken(text(client.userId, "user"), "user");
53
+ if (client.agentId) safe.agentId = redactedToken(text(client.agentId, "agent"), "agent");
54
+ if (client.teamIds) safe.teamIds = client.teamIds.map((id) => redactedToken(text(id, "team"), "team")).slice(0, 50);
55
+ if (client.keyId) safe.keyId = redactedToken(text(client.keyId, "key"), "key");
56
+ if (client.project) safe.project = redactedToken(text(client.project, "project"), "project");
57
+ return safe;
58
+ }
59
+
60
+ function safeName(value: string): string {
61
+ return value.replace(/[^a-z0-9._:-]/gi, "_").slice(0, 96) || "request";
62
+ }
63
+
64
+ function finiteNumber(value: number): number {
65
+ return Number.isFinite(value) ? value : 0;
66
+ }
67
+
68
+ function finiteOptional(value: number | undefined): number | undefined {
69
+ return Number.isFinite(value) ? value : undefined;
70
+ }
71
+
72
+ function text(value: unknown, fallback: string): string {
73
+ return typeof value === "string" ? value : fallback;
74
+ }
75
+
76
+ function safeToken(value: string, fallback: string): string {
77
+ const cleaned = value.replace(/[^a-z0-9._:@/-]/gi, "_").slice(0, 96);
78
+ return cleaned || fallback;
79
+ }
80
+
81
+ function redactedToken(value: string, fallback: string): string {
82
+ return safeToken(redactSecrets(value).text, fallback);
83
+ }
84
+
85
+ function safeSource(value: string): NonNullable<AuditManifest["client"]>["source"] {
86
+ return value === "user" || value === "agent" || value === "api_key" || value === "unattributed" ? value : "unattributed";
87
+ }
88
+
89
+ function safePath(path: string): string {
90
+ try {
91
+ return new URL(path, "http://local").pathname || "/";
92
+ } catch {
93
+ return path.split("?")[0] || "/";
94
+ }
95
+ }
96
+
97
+ function safeRetrievalId(value: string): string | undefined {
98
+ const redacted = redactSecrets(value).text;
99
+ return /^molenkopf:\/\/sha256\/[a-f0-9]{64}$/.test(redacted) ? redacted : undefined;
100
+ }
101
+
102
+ function safeWarning(value: string): string {
103
+ const cleaned = value.replace(/[^a-z0-9._:@/ -]/gi, "_").replace(/\s+/g, " ").trim().slice(0, 160);
104
+ return cleaned || "warning";
105
+ }
106
+
107
+ function stringArray(value: unknown): value is string[] {
108
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
109
+ }
110
+
111
+ function finiteOptionalNumber(value: unknown): boolean {
112
+ return value === undefined || (typeof value === "number" && Number.isFinite(value));
113
+ }