@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.
- package/.env.example +2 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/SECURITY.md +36 -0
- package/bin/launcher.js +76 -0
- package/bin/molenkopf.js +4 -0
- package/docs/DEPLOYMENT.md +104 -0
- package/docs/MOLENKOPF_PLUGIN_API.md +113 -0
- package/docs/MOLENKOPF_PROVIDER_ENV.md +123 -0
- package/docs/MOLENKOPF_USAGE.md +195 -0
- package/docs/PRODUCT_INTENT.md +36 -0
- package/docs/THREAT_MODEL.md +94 -0
- package/molenkopf.config.example.json +68 -0
- package/package.json +98 -0
- package/packages/core/src/auth/password.ts +47 -0
- package/packages/core/src/auth/session.ts +64 -0
- package/packages/core/src/ci/ci-mode.ts +71 -0
- package/packages/core/src/compression/content-classifier.ts +25 -0
- package/packages/core/src/compression/context-compressor.ts +48 -0
- package/packages/core/src/compression/json-compressor.ts +54 -0
- package/packages/core/src/compression/log-compressor.ts +32 -0
- package/packages/core/src/compression/operational-block-compressor.ts +43 -0
- package/packages/core/src/compression/stacktrace-compressor.ts +23 -0
- package/packages/core/src/config/config-policies.ts +146 -0
- package/packages/core/src/config/molenkopf-config.ts +137 -0
- package/packages/core/src/config/provider-config.ts +139 -0
- package/packages/core/src/events/event-bus.ts +88 -0
- package/packages/core/src/identity/api-keys.ts +149 -0
- package/packages/core/src/identity/budget.ts +51 -0
- package/packages/core/src/identity/db.ts +68 -0
- package/packages/core/src/identity/identity-store.ts +175 -0
- package/packages/core/src/identity/identity-validation.ts +102 -0
- package/packages/core/src/identity/key-permissions.ts +18 -0
- package/packages/core/src/identity/pricing.ts +11 -0
- package/packages/core/src/identity/types.ts +87 -0
- package/packages/core/src/identity/usage-snapshot.ts +116 -0
- package/packages/core/src/manifest/audit-activity.ts +74 -0
- package/packages/core/src/manifest/audit-metrics.ts +7 -0
- package/packages/core/src/manifest/audit-safety.ts +113 -0
- package/packages/core/src/manifest/audit-store.ts +189 -0
- package/packages/core/src/manifest/audit-summary.ts +184 -0
- package/packages/core/src/manifest/usage-meter.ts +105 -0
- package/packages/core/src/memory/memory-extractor.ts +57 -0
- package/packages/core/src/memory/memory-graph.ts +55 -0
- package/packages/core/src/pipeline/json-string-spans.ts +143 -0
- package/packages/core/src/pipeline/openai-request-rewriter.ts +66 -0
- package/packages/core/src/plugins/builtin-plugin-descriptors.ts +10 -0
- package/packages/core/src/plugins/builtin-plugin-modules.ts +9 -0
- package/packages/core/src/plugins/plugin-api.ts +96 -0
- package/packages/core/src/plugins/plugin-catalog.ts +42 -0
- package/packages/core/src/plugins/plugin-descriptor.ts +51 -0
- package/packages/core/src/plugins/plugin-sdk.ts +47 -0
- package/packages/core/src/plugins/static-pipeline.ts +5 -0
- package/packages/core/src/profiles/profile-router.ts +45 -0
- package/packages/core/src/providers/provider-catalog.ts +186 -0
- package/packages/core/src/routing/distribution.ts +31 -0
- package/packages/core/src/security/secret-redactor.ts +139 -0
- package/packages/core/src/security/target-policy.ts +61 -0
- package/packages/core/src/storage/local-paths.ts +6 -0
- package/packages/core/src/storage/private-state.ts +30 -0
- package/packages/core/src/storage/purge-dir.ts +10 -0
- package/packages/core/src/store/retrieval-store.ts +114 -0
- package/packages/core/src/utils/hash.ts +9 -0
- package/packages/core/src/utils/text.ts +18 -0
- package/packages/core/src/utils/tokens.ts +3 -0
- package/packages/dashboard/dist/assets/index-B_aSPgHx.js +11 -0
- package/packages/dashboard/dist/assets/index-D6z2TEL2.css +1 -0
- package/packages/dashboard/dist/favicon.png +0 -0
- package/packages/dashboard/dist/index.html +15 -0
- package/packages/dashboard/dist/molenkopf-logo.png +0 -0
- package/packages/dashboard/public/favicon.png +0 -0
- package/packages/dashboard/public/molenkopf-logo.png +0 -0
- package/packages/plugins/context-compressor-plugin/descriptor.ts +19 -0
- package/packages/plugins/context-compressor-plugin/page.html +191 -0
- package/packages/plugins/context-compressor-plugin/plugin.ts +40 -0
- package/packages/plugins/obsidian-graph-plugin/descriptor.ts +19 -0
- package/packages/plugins/obsidian-graph-plugin/page.html +68 -0
- package/packages/plugins/obsidian-graph-plugin/plugin.ts +27 -0
- package/packages/plugins/shared/audit-projects.ts +32 -0
- package/packages/proxy/src/cli/args.ts +34 -0
- package/packages/proxy/src/cli/config-loader.ts +43 -0
- package/packages/proxy/src/cli/env-file.ts +43 -0
- package/packages/proxy/src/cli/main.ts +132 -0
- package/packages/proxy/src/cli/profile-server.ts +176 -0
- package/packages/proxy/src/cli/target.ts +7 -0
- package/packages/proxy/src/http/agent-drafts.ts +103 -0
- package/packages/proxy/src/http/agent-router.ts +69 -0
- package/packages/proxy/src/http/audit-view.ts +15 -0
- package/packages/proxy/src/http/auth-state.ts +44 -0
- package/packages/proxy/src/http/budget-gate.ts +45 -0
- package/packages/proxy/src/http/budget-warnings.ts +7 -0
- package/packages/proxy/src/http/cli-stream-response.ts +51 -0
- package/packages/proxy/src/http/client-identity.ts +51 -0
- package/packages/proxy/src/http/communication-graph.ts +139 -0
- package/packages/proxy/src/http/control-plane-guard.ts +56 -0
- package/packages/proxy/src/http/dashboard-assets.ts +115 -0
- package/packages/proxy/src/http/encoded-usage-meter.ts +32 -0
- package/packages/proxy/src/http/header-utils.ts +65 -0
- package/packages/proxy/src/http/identity-id.ts +11 -0
- package/packages/proxy/src/http/local-api-agent-actions.ts +17 -0
- package/packages/proxy/src/http/local-api-auth.ts +120 -0
- package/packages/proxy/src/http/local-api-consumer-actions.ts +20 -0
- package/packages/proxy/src/http/local-api-identity.ts +194 -0
- package/packages/proxy/src/http/local-api-io.ts +82 -0
- package/packages/proxy/src/http/local-api-keys.ts +126 -0
- package/packages/proxy/src/http/local-api-pipeline.ts +41 -0
- package/packages/proxy/src/http/local-api-plugin-actions.ts +31 -0
- package/packages/proxy/src/http/local-api-provider-actions.ts +181 -0
- package/packages/proxy/src/http/local-api-retention.ts +28 -0
- package/packages/proxy/src/http/local-api-runtime-auth.ts +119 -0
- package/packages/proxy/src/http/local-api-scope.ts +47 -0
- package/packages/proxy/src/http/local-api-state.ts +180 -0
- package/packages/proxy/src/http/local-api.ts +166 -0
- package/packages/proxy/src/http/password-policy.ts +5 -0
- package/packages/proxy/src/http/plugin-data.ts +38 -0
- package/packages/proxy/src/http/plugin-host.ts +87 -0
- package/packages/proxy/src/http/plugin-modules.ts +1 -0
- package/packages/proxy/src/http/plugin-page-loader.ts +24 -0
- package/packages/proxy/src/http/plugin-pipeline.ts +125 -0
- package/packages/proxy/src/http/provider-access.ts +33 -0
- package/packages/proxy/src/http/provider-http-test.ts +133 -0
- package/packages/proxy/src/http/provider-input.ts +39 -0
- package/packages/proxy/src/http/provider-routing-snapshot.ts +28 -0
- package/packages/proxy/src/http/provider-test.ts +149 -0
- package/packages/proxy/src/http/proxy-identity.ts +78 -0
- package/packages/proxy/src/http/public-bind.ts +8 -0
- package/packages/proxy/src/http/request-finish.ts +62 -0
- package/packages/proxy/src/http/request-path.ts +8 -0
- package/packages/proxy/src/http/request-policy.ts +46 -0
- package/packages/proxy/src/http/runtime-auth-proof.ts +55 -0
- package/packages/proxy/src/http/runtime-auth-registry.ts +105 -0
- package/packages/proxy/src/http/runtime-settings.ts +199 -0
- package/packages/proxy/src/http/runtime-state.ts +198 -0
- package/packages/proxy/src/http/server-io.ts +80 -0
- package/packages/proxy/src/http/server-types.ts +17 -0
- package/packages/proxy/src/http/server.ts +190 -0
- package/packages/proxy/src/http/session-secret.ts +19 -0
- package/packages/proxy/src/http/streaming-proxy.ts +88 -0
- package/packages/proxy/src/http/usage-accounting.ts +100 -0
- package/packages/proxy/src/http/usage-restore.ts +15 -0
- package/packages/proxy/src/runtime/cli-diagnostics.ts +64 -0
- package/packages/proxy/src/runtime/cli-env.ts +22 -0
- package/packages/proxy/src/runtime/cli-executor.ts +134 -0
- package/packages/proxy/src/runtime/cli-provider.ts +162 -0
- package/packages/proxy/src/runtime/cli-request.ts +79 -0
- package/packages/proxy/src/runtime/codex-runtime-config.ts +37 -0
- package/packages/proxy/src/runtime/runtime-profile.ts +170 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { readFile, readdir, rename, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { defaultDataDir } from "../storage/local-paths.ts";
|
|
5
|
+
import { chmodPrivateSync, ensurePrivateDirSync, PRIVATE_FILE_MODE } from "../storage/private-state.ts";
|
|
6
|
+
import { purgeChildDir } from "../storage/purge-dir.ts";
|
|
7
|
+
import { isAuditManifest, normalizedManifest } from "./audit-safety.ts";
|
|
8
|
+
|
|
9
|
+
export type AuditManifest = {
|
|
10
|
+
requestId: string;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
method: string;
|
|
13
|
+
path: string;
|
|
14
|
+
targetHost: string;
|
|
15
|
+
providerId?: string;
|
|
16
|
+
client?: { id: string; label: string; source: "user" | "agent" | "api_key" | "unattributed"; userId?: string; agentId?: string; teamIds?: string[]; keyId?: string; project?: string };
|
|
17
|
+
compressedItems: number;
|
|
18
|
+
estimatedOriginalTokens: number;
|
|
19
|
+
estimatedCompressedTokens: number;
|
|
20
|
+
estimatedSavedTokens: number;
|
|
21
|
+
redactedSecrets: number;
|
|
22
|
+
retrievalIds: string[];
|
|
23
|
+
compressorsUsed: string[];
|
|
24
|
+
warnings: string[];
|
|
25
|
+
statusCode?: number;
|
|
26
|
+
durationMs?: number;
|
|
27
|
+
upstreamInputTokens?: number;
|
|
28
|
+
upstreamOutputTokens?: number;
|
|
29
|
+
};
|
|
30
|
+
export type AuditRetention = { maxFiles?: number; maxBytes?: number; maxAgeMs?: number };
|
|
31
|
+
export type AuditStoreOptions = { retention?: AuditRetention; now?: () => Date };
|
|
32
|
+
export type AuditPage = { items: AuditManifest[]; nextCursor?: string; skippedCorrupt: number };
|
|
33
|
+
|
|
34
|
+
export class AuditCursorError extends Error {
|
|
35
|
+
constructor() { super("invalid audit cursor"); this.name = "AuditCursorError"; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class AuditStore {
|
|
39
|
+
private root: string;
|
|
40
|
+
private retention: AuditRetention;
|
|
41
|
+
private now: () => Date;
|
|
42
|
+
|
|
43
|
+
constructor(root = defaultDataDir(), options: AuditStoreOptions = {}) {
|
|
44
|
+
this.root = root; this.retention = options.retention ?? {}; this.now = options.now ?? (() => new Date());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async write(manifest: AuditManifest): Promise<void> {
|
|
48
|
+
const safe = normalizedManifest(manifest);
|
|
49
|
+
const dir = join(this.root, "audit");
|
|
50
|
+
ensurePrivateDirSync(dir);
|
|
51
|
+
const name = `${safe.timestamp.replace(/[:.]/g, "-")}-${safeName(safe.requestId)}.json`;
|
|
52
|
+
const tmp = join(dir, `tmp-${process.pid}-${Date.now()}-${safeName(safe.requestId)}.json.tmp`);
|
|
53
|
+
const final = join(dir, name);
|
|
54
|
+
writeFileSync(tmp, JSON.stringify(safe, null, 2), privateWriteOptions());
|
|
55
|
+
chmodPrivateSync(tmp, PRIVATE_FILE_MODE);
|
|
56
|
+
renameSync(tmp, final);
|
|
57
|
+
chmodPrivateSync(final, PRIVATE_FILE_MODE);
|
|
58
|
+
await this.enforceRetention(dir);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async latest(): Promise<AuditManifest | undefined> {
|
|
62
|
+
return this.latestFast();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async latestFast(): Promise<AuditManifest | undefined> {
|
|
66
|
+
return (await this.listPage({ limit: 1, newestFirst: true })).items[0];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async listPage(options: { limit?: number; cursor?: string; newestFirst?: boolean; filter?: (item: AuditManifest) => boolean } = {}): Promise<AuditPage> {
|
|
70
|
+
const dir = join(this.root, "audit");
|
|
71
|
+
const limit = Math.max(1, Math.min(1000, Math.floor(options.limit ?? 100)));
|
|
72
|
+
let files = await auditFiles(dir);
|
|
73
|
+
if (options.newestFirst) files = files.reverse();
|
|
74
|
+
const start = options.cursor ? cursorIndex(files, options.cursor) + 1 : 0;
|
|
75
|
+
const items: AuditManifest[] = [];
|
|
76
|
+
let skippedCorrupt = 0, index = Math.max(0, start), lastFile: string | undefined;
|
|
77
|
+
for (; index < files.length && items.length < limit; index++) {
|
|
78
|
+
const file = files[index];
|
|
79
|
+
const item = await readManifest(dir, file);
|
|
80
|
+
if (item && (!options.filter || options.filter(item))) { items.push(item); lastFile = file; } else if (!item) { skippedCorrupt++; await quarantine(dir, file); }
|
|
81
|
+
}
|
|
82
|
+
return { items, skippedCorrupt, nextCursor: index < files.length && lastFile ? encodeCursor(lastFile) : undefined };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async list(): Promise<AuditManifest[]> {
|
|
86
|
+
const dir = join(this.root, "audit");
|
|
87
|
+
const files = await auditFiles(dir);
|
|
88
|
+
const out: AuditManifest[] = [];
|
|
89
|
+
for (const file of files) {
|
|
90
|
+
const item = await readManifest(dir, file);
|
|
91
|
+
if (item) out.push(item); else await quarantine(dir, file);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async purgeAll(): Promise<void> {
|
|
97
|
+
await purgeChildDir(this.root, "audit");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async enforceRetention(dir: string): Promise<void> {
|
|
101
|
+
if (!this.retention.maxAgeMs && !this.retention.maxFiles && !this.retention.maxBytes) return;
|
|
102
|
+
const files = await auditFiles(dir).catch(() => []);
|
|
103
|
+
const now = this.now().getTime();
|
|
104
|
+
const entries = await Promise.all(files.map(async (file) => ({ file, size: await fileSize(join(dir, file)), ageMs: now - fileTime(file) })));
|
|
105
|
+
let kept = entries.filter((entry) => !this.retention.maxAgeMs || entry.ageMs <= this.retention.maxAgeMs);
|
|
106
|
+
for (const entry of entries) if (!kept.includes(entry)) await rm(join(dir, entry.file), { force: true });
|
|
107
|
+
if (this.retention.maxFiles && kept.length > this.retention.maxFiles) {
|
|
108
|
+
for (const entry of kept.slice(0, kept.length - this.retention.maxFiles)) await rm(join(dir, entry.file), { force: true });
|
|
109
|
+
kept = kept.slice(-this.retention.maxFiles);
|
|
110
|
+
}
|
|
111
|
+
while (this.retention.maxBytes && kept.reduce((sum, entry) => sum + entry.size, 0) > this.retention.maxBytes && kept.length) {
|
|
112
|
+
const [entry, ...rest] = kept;
|
|
113
|
+
await rm(join(dir, entry.file), { force: true });
|
|
114
|
+
kept = rest;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function auditFiles(dir: string): Promise<string[]> {
|
|
120
|
+
try {
|
|
121
|
+
return (await readdir(dir)).filter((file) => file.endsWith(".json") && !file.endsWith(".corrupt.json")).sort();
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (isFsCode(err, "ENOENT")) return [];
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function readManifest(dir: string, file: string): Promise<AuditManifest | undefined> {
|
|
129
|
+
let raw: string;
|
|
130
|
+
try {
|
|
131
|
+
raw = await readFile(join(dir, file), "utf8");
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (isFsCode(err, "ENOENT")) return undefined;
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const value = JSON.parse(raw) as unknown;
|
|
138
|
+
if (!isAuditManifest(value)) return undefined;
|
|
139
|
+
try {
|
|
140
|
+
return normalizedManifest(value);
|
|
141
|
+
} catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (err instanceof SyntaxError) return undefined;
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isFsCode(err: unknown, code: string): boolean {
|
|
151
|
+
return Boolean(err && typeof err === "object" && "code" in err && (err as { code?: unknown }).code === code);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function quarantine(dir: string, file: string): Promise<void> {
|
|
155
|
+
await rename(join(dir, file), join(dir, `${file}.corrupt`)).catch(() => {});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function safeName(value: string): string {
|
|
159
|
+
return value.replace(/[^a-z0-9._:-]/gi, "_").slice(0, 96) || "request";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function encodeCursor(file: string): string {
|
|
163
|
+
return Buffer.from(JSON.stringify({ file }), "utf8").toString("base64url");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function cursorIndex(files: string[], cursor: string): number {
|
|
167
|
+
try {
|
|
168
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")) as { file?: unknown };
|
|
169
|
+
if (typeof parsed.file !== "string") throw new Error("bad");
|
|
170
|
+
const index = files.indexOf(parsed.file);
|
|
171
|
+
if (index < 0) throw new Error("bad");
|
|
172
|
+
return index;
|
|
173
|
+
} catch {
|
|
174
|
+
throw new AuditCursorError();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function fileTime(file: string): number {
|
|
179
|
+
const stamp = file.slice(0, 24).replace(/T(\d\d)-(\d\d)-(\d\d)-(\d\d\d)Z/, "T$1:$2:$3.$4Z");
|
|
180
|
+
return Date.parse(stamp) || 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function fileSize(path: string): Promise<number> {
|
|
184
|
+
try { return (await stat(path)).size; } catch { return 0; }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function privateWriteOptions(): { mode: number } | undefined {
|
|
188
|
+
return process.platform === "win32" ? undefined : { mode: PRIVATE_FILE_MODE };
|
|
189
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { AuditManifest } from "./audit-store.ts";
|
|
2
|
+
import { confirmedSavedTokens } from "./audit-metrics.ts";
|
|
3
|
+
|
|
4
|
+
export type AuditSummaryTotals = {
|
|
5
|
+
requests: number;
|
|
6
|
+
ok: number;
|
|
7
|
+
errors: number;
|
|
8
|
+
unknown: number;
|
|
9
|
+
originalTokens: number;
|
|
10
|
+
forwardedTokens: number;
|
|
11
|
+
savedTokens: number;
|
|
12
|
+
savedPercent: number;
|
|
13
|
+
upstreamInputTokens: number;
|
|
14
|
+
upstreamOutputTokens: number;
|
|
15
|
+
compressedItems: number;
|
|
16
|
+
redactedSecrets: number;
|
|
17
|
+
warnings: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type AuditSummaryCount = { id: string; label: string; count: number };
|
|
21
|
+
|
|
22
|
+
export type AuditSummaryStatusTotals = {
|
|
23
|
+
ok: number;
|
|
24
|
+
errors: number;
|
|
25
|
+
unknown: number;
|
|
26
|
+
byClass: AuditSummaryCount[];
|
|
27
|
+
byCode: AuditSummaryCount[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type AuditSummaryWarningTotals = { requests: number; warnings: number };
|
|
31
|
+
|
|
32
|
+
export type AuditSummaryBucket = AuditSummaryTotals & {
|
|
33
|
+
id: string;
|
|
34
|
+
label: string;
|
|
35
|
+
source: string;
|
|
36
|
+
project?: string;
|
|
37
|
+
latestAt?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type AuditSummaryBreakdown = AuditSummaryTotals & { id: string; label: string; latestAt?: string };
|
|
41
|
+
|
|
42
|
+
export type AuditSummary = AuditSummaryTotals & {
|
|
43
|
+
buckets: AuditSummaryBucket[];
|
|
44
|
+
statusTotals: AuditSummaryStatusTotals;
|
|
45
|
+
warningTotals: AuditSummaryWarningTotals;
|
|
46
|
+
providers: AuditSummaryBreakdown[];
|
|
47
|
+
endpoints: AuditSummaryBreakdown[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type MutableTotals = Omit<AuditSummaryTotals, "savedPercent">;
|
|
51
|
+
type MutableBucket = Omit<AuditSummaryBucket, "savedPercent">;
|
|
52
|
+
type MutableBreakdown = Omit<AuditSummaryBreakdown, "savedPercent">;
|
|
53
|
+
|
|
54
|
+
type StatusAccumulator = { unknown: number; byClass: Map<string, AuditSummaryCount>; byCode: Map<string, AuditSummaryCount> };
|
|
55
|
+
|
|
56
|
+
export function summarizeAudit(manifests: AuditManifest[]): AuditSummary {
|
|
57
|
+
const totals = empty();
|
|
58
|
+
const buckets = new Map<string, MutableBucket>();
|
|
59
|
+
const providers = new Map<string, MutableBreakdown>();
|
|
60
|
+
const endpoints = new Map<string, MutableBreakdown>();
|
|
61
|
+
const status = emptyStatus();
|
|
62
|
+
let warningRequests = 0;
|
|
63
|
+
for (const manifest of manifests) {
|
|
64
|
+
add(totals, manifest);
|
|
65
|
+
addStatus(status, manifest.statusCode);
|
|
66
|
+
if (manifest.warnings.length > 0) warningRequests++;
|
|
67
|
+
const client = manifest.client ?? { id: "unattributed", label: "unattributed client", source: "unattributed" as const };
|
|
68
|
+
addGroup(bucketFor(buckets, client), manifest);
|
|
69
|
+
addGroup(breakdownFor(providers, providerId(manifest), providerLabel(manifest)), manifest);
|
|
70
|
+
addGroup(breakdownFor(endpoints, endpointId(manifest), endpointLabel(manifest)), manifest);
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
...finish(totals),
|
|
74
|
+
buckets: sortGroups([...buckets.values()].map(finish)),
|
|
75
|
+
statusTotals: {
|
|
76
|
+
ok: totals.ok,
|
|
77
|
+
errors: totals.errors,
|
|
78
|
+
unknown: status.unknown,
|
|
79
|
+
byClass: sortCounts(status.byClass),
|
|
80
|
+
byCode: sortCounts(status.byCode)
|
|
81
|
+
},
|
|
82
|
+
warningTotals: { requests: warningRequests, warnings: totals.warnings },
|
|
83
|
+
providers: sortGroups([...providers.values()].map(finish)),
|
|
84
|
+
endpoints: sortGroups([...endpoints.values()].map(finish))
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function empty(): MutableTotals {
|
|
89
|
+
return { requests: 0, ok: 0, errors: 0, unknown: 0, originalTokens: 0, forwardedTokens: 0, savedTokens: 0, upstreamInputTokens: 0, upstreamOutputTokens: 0, compressedItems: 0, redactedSecrets: 0, warnings: 0 };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function emptyStatus(): StatusAccumulator { return { unknown: 0, byClass: new Map(), byCode: new Map() }; }
|
|
93
|
+
|
|
94
|
+
function add(target: MutableTotals, manifest: AuditManifest) {
|
|
95
|
+
target.requests++;
|
|
96
|
+
const status = statusKind(manifest.statusCode);
|
|
97
|
+
if (status === "ok") target.ok++;
|
|
98
|
+
else if (status === "error") target.errors++;
|
|
99
|
+
else target.unknown++;
|
|
100
|
+
target.originalTokens += manifest.estimatedOriginalTokens;
|
|
101
|
+
target.forwardedTokens += manifest.estimatedCompressedTokens;
|
|
102
|
+
target.savedTokens += confirmedSavedTokens(manifest);
|
|
103
|
+
target.upstreamInputTokens += manifest.upstreamInputTokens ?? 0;
|
|
104
|
+
target.upstreamOutputTokens += manifest.upstreamOutputTokens ?? 0;
|
|
105
|
+
target.compressedItems += manifest.compressedItems;
|
|
106
|
+
target.redactedSecrets += manifest.redactedSecrets;
|
|
107
|
+
target.warnings += manifest.warnings.length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function addGroup<T extends MutableTotals & { latestAt?: string }>(target: T, manifest: AuditManifest): T {
|
|
111
|
+
add(target, manifest);
|
|
112
|
+
if (!target.latestAt || manifest.timestamp > target.latestAt) target.latestAt = manifest.timestamp;
|
|
113
|
+
return target;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function addStatus(status: StatusAccumulator, statusCode: number | undefined) {
|
|
117
|
+
const kind = statusKind(statusCode);
|
|
118
|
+
if (statusCode === undefined) {
|
|
119
|
+
status.unknown++;
|
|
120
|
+
bump(status.byCode, "unknown", "unknown");
|
|
121
|
+
bump(status.byClass, "unknown", "unknown");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const code = String(statusCode);
|
|
125
|
+
const statusClass = kind === "unknown" && (statusCode < 100 || statusCode > 599) ? "unknown" : `${Math.floor(statusCode / 100)}xx`;
|
|
126
|
+
if (kind === "unknown") status.unknown++;
|
|
127
|
+
bump(status.byCode, code, code);
|
|
128
|
+
bump(status.byClass, statusClass, statusClass);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function bucketFor(map: Map<string, MutableBucket>, client: NonNullable<AuditManifest["client"]>): MutableBucket {
|
|
132
|
+
const id = client.keyId ? `key:${client.keyId}:project:${client.project ?? "none"}` : client.id;
|
|
133
|
+
const bucket = map.get(id) ?? { id, label: client.label, source: client.source, project: client.project, ...empty() };
|
|
134
|
+
bucket.project ??= client.project;
|
|
135
|
+
map.set(id, bucket);
|
|
136
|
+
return bucket;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function statusKind(statusCode: number | undefined): "ok" | "error" | "unknown" {
|
|
140
|
+
return !Number.isInteger(statusCode) ? "unknown" : statusCode >= 200 && statusCode <= 399 ? "ok" : statusCode >= 400 && statusCode <= 599 ? "error" : "unknown";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function breakdownFor(map: Map<string, MutableBreakdown>, id: string, label: string): MutableBreakdown {
|
|
144
|
+
const item = map.get(id) ?? { id, label, ...empty() };
|
|
145
|
+
map.set(id, item);
|
|
146
|
+
return item;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function finish<T extends MutableTotals>(totals: T): T & { savedPercent: number } {
|
|
150
|
+
return { ...totals, savedPercent: percent(totals.savedTokens, totals.originalTokens) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function percent(part: number, total: number): number {
|
|
154
|
+
if (total <= 0 || part <= 0) return 0;
|
|
155
|
+
return Math.round((part / total) * 10000) / 100;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function sortGroups<T extends AuditSummaryTotals & { id: string }>(items: T[]): T[] {
|
|
159
|
+
return items.sort((a, b) => b.savedTokens - a.savedTokens || b.requests - a.requests || a.id.localeCompare(b.id));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function sortCounts(map: Map<string, AuditSummaryCount>): AuditSummaryCount[] {
|
|
163
|
+
return [...map.values()].sort((a, b) => b.count - a.count || a.id.localeCompare(b.id));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function bump(map: Map<string, AuditSummaryCount>, id: string, label: string) {
|
|
167
|
+
const item = map.get(id) ?? { id, label, count: 0 };
|
|
168
|
+
item.count++;
|
|
169
|
+
map.set(id, item);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function providerId(manifest: AuditManifest): string {
|
|
173
|
+
return manifest.providerId ? `provider:${manifest.providerId}` : `provider-host:${providerLabel(manifest)}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function providerLabel(manifest: AuditManifest): string { return manifest.providerId || manifest.targetHost || "unknown"; }
|
|
177
|
+
|
|
178
|
+
function endpointId(manifest: AuditManifest): string { return `endpoint:${manifest.method}:${endpointPath(manifest.path)}`; }
|
|
179
|
+
|
|
180
|
+
function endpointLabel(manifest: AuditManifest): string { return `${manifest.method} ${endpointPath(manifest.path)}`; }
|
|
181
|
+
|
|
182
|
+
function endpointPath(path: string): string {
|
|
183
|
+
try { return new URL(path, "http://molenkopf.local").pathname || "/"; } catch { return "unknown"; }
|
|
184
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export type UsageTotals = { inputTokens?: number; outputTokens?: number };
|
|
2
|
+
|
|
3
|
+
const MAX_TOKEN_VALUE = 1_000_000_000;
|
|
4
|
+
const MAX_BUFFER_CHARS = 2_000_000;
|
|
5
|
+
|
|
6
|
+
export function createUsageMeter() {
|
|
7
|
+
let input: number | undefined;
|
|
8
|
+
let output: number | undefined;
|
|
9
|
+
let buffer = "";
|
|
10
|
+
let sseBuffer = "";
|
|
11
|
+
return {
|
|
12
|
+
feed(chunk: Buffer | string): void {
|
|
13
|
+
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
14
|
+
buffer = trimBuffer(buffer + text);
|
|
15
|
+
sseBuffer += text;
|
|
16
|
+
sseBuffer = consumeSseEvents(sseBuffer, (event) => applyUsage(eventUsage(event)));
|
|
17
|
+
},
|
|
18
|
+
result(): UsageTotals {
|
|
19
|
+
applyUsage(jsonUsage(buffer));
|
|
20
|
+
return { inputTokens: input, outputTokens: output };
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function applyUsage(usage: UsageTotals | undefined) {
|
|
25
|
+
input = maxToken(input, usage?.inputTokens);
|
|
26
|
+
output = maxToken(output, usage?.outputTokens);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function consumeSseEvents(text: string, onEvent: (event: SseEvent) => void): string {
|
|
31
|
+
let start = 0;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const lf = text.indexOf("\n\n", start);
|
|
34
|
+
const crlf = text.indexOf("\r\n\r\n", start);
|
|
35
|
+
const end = lf === -1 ? crlf : crlf === -1 ? lf : Math.min(lf, crlf);
|
|
36
|
+
if (end === -1) break;
|
|
37
|
+
const raw = text.slice(start, end);
|
|
38
|
+
const event = parseSseEvent(raw);
|
|
39
|
+
if (event) onEvent(event);
|
|
40
|
+
start = end + (text.startsWith("\r\n\r\n", end) ? 4 : 2);
|
|
41
|
+
}
|
|
42
|
+
return trimBuffer(text.slice(start));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type SseEvent = { type?: string; data: unknown };
|
|
46
|
+
|
|
47
|
+
function parseSseEvent(raw: string): SseEvent | undefined {
|
|
48
|
+
let type: string | undefined;
|
|
49
|
+
const data: string[] = [];
|
|
50
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
51
|
+
if (line.startsWith("event:")) type = line.slice(6).trim();
|
|
52
|
+
if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
|
|
53
|
+
}
|
|
54
|
+
const body = data.join("\n").trim();
|
|
55
|
+
if (!body || body === "[DONE]") return undefined;
|
|
56
|
+
try {
|
|
57
|
+
return { type, data: JSON.parse(body) };
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function eventUsage(event: SseEvent): UsageTotals | undefined {
|
|
64
|
+
if (!isRecord(event.data)) return undefined;
|
|
65
|
+
if (event.type === "message_start" && isRecord(event.data.message)) return usageObject(event.data.message.usage);
|
|
66
|
+
if (event.type === "message_delta") return usageObject(event.data.usage);
|
|
67
|
+
if (isRecord(event.data.response)) return usageObject(event.data.response.usage);
|
|
68
|
+
return usageObject(event.data.usage);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function jsonUsage(text: string): UsageTotals | undefined {
|
|
72
|
+
const trimmed = text.trim();
|
|
73
|
+
if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) return undefined;
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(trimmed);
|
|
76
|
+
return isRecord(parsed) ? usageObject(parsed.usage) : undefined;
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function usageObject(value: unknown): UsageTotals | undefined {
|
|
83
|
+
if (!isRecord(value)) return undefined;
|
|
84
|
+
return {
|
|
85
|
+
inputTokens: token(value.input_tokens) ?? token(value.prompt_tokens),
|
|
86
|
+
outputTokens: token(value.output_tokens) ?? token(value.completion_tokens)
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function token(value: unknown): number | undefined {
|
|
91
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= MAX_TOKEN_VALUE ? value : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function maxToken(current: number | undefined, next: number | undefined): number | undefined {
|
|
95
|
+
if (next === undefined) return current;
|
|
96
|
+
return current === undefined || next > current ? next : current;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function trimBuffer(text: string): string {
|
|
100
|
+
return text.length > MAX_BUFFER_CHARS ? text.slice(-MAX_BUFFER_CHARS) : text;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
104
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
105
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { shortHash } from "../utils/hash.ts";
|
|
2
|
+
|
|
3
|
+
// Derives a small, safe set of memory concepts from the real transferred text:
|
|
4
|
+
// file paths, code symbols, and error types. Only short derived tokens are kept
|
|
5
|
+
// (never raw prompts). Callers must pass already-redacted text.
|
|
6
|
+
|
|
7
|
+
export type ConceptKind = "file" | "symbol" | "error";
|
|
8
|
+
export type Concept = { id: string; label: string; kind: ConceptKind };
|
|
9
|
+
|
|
10
|
+
const FILE_RE = /\b[\w./-]{2,60}\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|rb|json|sql|md|yml|yaml|toml|sh)\b/g;
|
|
11
|
+
const SYMBOL_RE = /\b(?:function|class|interface|type|def|func|struct)\s+([A-Za-z_][A-Za-z0-9_]{2,40})/g;
|
|
12
|
+
const ERROR_RE = /\b([A-Z][A-Za-z0-9_]*(?:Error|Exception))\b/g;
|
|
13
|
+
|
|
14
|
+
const PER_KIND = 6;
|
|
15
|
+
const MAX_CONCEPTS = 8;
|
|
16
|
+
|
|
17
|
+
export function extractConcepts(text: string): Concept[] {
|
|
18
|
+
if (typeof text !== "string" || text.length < 3) return [];
|
|
19
|
+
const files = collect(text, FILE_RE, 0, "file");
|
|
20
|
+
const symbols = collect(text, SYMBOL_RE, 1, "symbol");
|
|
21
|
+
const errors = collect(text, ERROR_RE, 1, "error");
|
|
22
|
+
const merged: Concept[] = [];
|
|
23
|
+
const seen = new Set<string>();
|
|
24
|
+
for (const concept of [...errors, ...files, ...symbols]) {
|
|
25
|
+
if (seen.has(concept.id)) continue;
|
|
26
|
+
seen.add(concept.id);
|
|
27
|
+
merged.push(concept);
|
|
28
|
+
if (merged.length >= MAX_CONCEPTS) break;
|
|
29
|
+
}
|
|
30
|
+
return merged;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function collect(text: string, re: RegExp, group: number, kind: ConceptKind): Concept[] {
|
|
34
|
+
re.lastIndex = 0;
|
|
35
|
+
const out: Concept[] = [];
|
|
36
|
+
const seen = new Set<string>();
|
|
37
|
+
let match: RegExpExecArray | null;
|
|
38
|
+
while ((match = re.exec(text)) !== null && out.length < PER_KIND) {
|
|
39
|
+
const raw = (match[group] ?? match[0]).trim();
|
|
40
|
+
const path = kind === "file" ? safePath(raw) : undefined;
|
|
41
|
+
const label = kind === "file" ? path?.split("/").pop() ?? "" : raw;
|
|
42
|
+
if (!label || label.length > 48 || /^[A-Za-z0-9_-]{24,}$/.test(label)) continue;
|
|
43
|
+
const id = kind === "file" ? `file:${path}` : `${kind}:${label}`;
|
|
44
|
+
if (seen.has(id)) continue;
|
|
45
|
+
seen.add(id);
|
|
46
|
+
out.push({ id, label, kind });
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function safePath(value: string): string | undefined {
|
|
52
|
+
const normalized = value.replace(/\\/g, "/").split("/").filter((part) => part && part !== "." && part !== "..").join("/");
|
|
53
|
+
if (!normalized) return undefined;
|
|
54
|
+
if (normalized.length <= 96 && /^[\w./-]+$/.test(normalized)) return normalized;
|
|
55
|
+
const base = normalized.split("/").pop() ?? "file";
|
|
56
|
+
return `${base.slice(0, 40)}-${shortHash(normalized)}`;
|
|
57
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Concept } from "./memory-extractor.ts";
|
|
2
|
+
|
|
3
|
+
// A text-derived memory graph: concept nodes (files, symbols, errors) linked by
|
|
4
|
+
// co-occurrence within the same agent request. Bounded so it stays a safe,
|
|
5
|
+
// renderable summary, not an unbounded log of traffic.
|
|
6
|
+
|
|
7
|
+
export type MemoryNode = { id: string; label: string; kind: string; count: number; lastSeen?: string };
|
|
8
|
+
export type MemoryEdge = { from: string; to: string; count: number };
|
|
9
|
+
export type MemoryGraph = { nodes: MemoryNode[]; edges: MemoryEdge[]; updatedAt?: string };
|
|
10
|
+
|
|
11
|
+
const MAX_NODES = 120;
|
|
12
|
+
const MAX_EDGES = 240;
|
|
13
|
+
const MAX_COUNT = 99999;
|
|
14
|
+
|
|
15
|
+
export function createMemoryGraph(): MemoryGraph {
|
|
16
|
+
return { nodes: [], edges: [] };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function recordConcepts(graph: MemoryGraph, concepts: Concept[], timestamp?: string): MemoryGraph {
|
|
20
|
+
const upserted = concepts.filter((concept) => upsert(graph, concept, timestamp));
|
|
21
|
+
const nodeIds = new Set(graph.nodes.map((node) => node.id));
|
|
22
|
+
const present = upserted.filter((concept) => nodeIds.has(concept.id));
|
|
23
|
+
for (let i = 0; i < present.length; i++) {
|
|
24
|
+
for (let j = i + 1; j < present.length; j++) link(graph, present[i].id, present[j].id);
|
|
25
|
+
}
|
|
26
|
+
if (concepts.length) graph.updatedAt = timestamp ?? graph.updatedAt;
|
|
27
|
+
return graph;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function upsert(graph: MemoryGraph, concept: Concept, timestamp = graph.updatedAt): boolean {
|
|
31
|
+
const found = graph.nodes.find((node) => node.id === concept.id);
|
|
32
|
+
if (found) {
|
|
33
|
+
found.count = Math.min(MAX_COUNT, found.count + 1);
|
|
34
|
+
found.lastSeen = timestamp ?? found.lastSeen;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (graph.nodes.length >= MAX_NODES) evictOne(graph);
|
|
38
|
+
if (graph.nodes.length >= MAX_NODES) return false;
|
|
39
|
+
graph.nodes.push({ id: concept.id, label: concept.label, kind: concept.kind, count: 1, lastSeen: timestamp });
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function evictOne(graph: MemoryGraph) {
|
|
44
|
+
const candidate = [...graph.nodes].sort((a, b) => a.count - b.count || (a.lastSeen ?? "").localeCompare(b.lastSeen ?? "") || a.id.localeCompare(b.id))[0];
|
|
45
|
+
if (!candidate) return;
|
|
46
|
+
graph.nodes = graph.nodes.filter((node) => node.id !== candidate.id);
|
|
47
|
+
graph.edges = graph.edges.filter((edge) => edge.from !== candidate.id && edge.to !== candidate.id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function link(graph: MemoryGraph, a: string, b: string) {
|
|
51
|
+
const [from, to] = a < b ? [a, b] : [b, a];
|
|
52
|
+
const found = graph.edges.find((edge) => edge.from === from && edge.to === to);
|
|
53
|
+
if (found) found.count = Math.min(MAX_COUNT, found.count + 1);
|
|
54
|
+
else if (graph.edges.length < MAX_EDGES) graph.edges.push({ from, to, count: 1 });
|
|
55
|
+
}
|