toolfunnel 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +231 -167
  2. package/bin/toolfunnel.js +372 -147
  3. package/docs/KNOWN_BUGS.md +99 -33
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/design.md +70 -0
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +31 -31
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -0
  12. package/mcp/servers/mock-upstream/server.js +192 -12
  13. package/package.json +1 -1
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +344 -344
  17. package/src/core/config-home.js +134 -134
  18. package/src/core/events.js +105 -105
  19. package/src/core/hook-engine.js +275 -275
  20. package/src/core/hook-loader.js +791 -791
  21. package/src/core/hook-runner.js +346 -346
  22. package/src/core/logger.js +171 -171
  23. package/src/core/matcher.js +51 -51
  24. package/src/core/metrics.js +75 -75
  25. package/src/core/requires.js +138 -138
  26. package/src/core/server-config.js +98 -77
  27. package/src/core/tool-registry.js +180 -180
  28. package/src/extend/add-hook.md +152 -142
  29. package/src/extend/add-mcp.md +122 -121
  30. package/src/extend/configure.md +87 -0
  31. package/src/extend/create-tool.md +168 -152
  32. package/src/extend/howto.js +102 -98
  33. package/src/extend/package.md +187 -187
  34. package/src/extend/wrap.md +99 -0
  35. package/src/mcp/aggregator.js +1246 -807
  36. package/src/mcp/expose-store.js +460 -430
  37. package/src/mcp/gated-run.js +201 -194
  38. package/src/mcp/http-transport.js +1311 -1012
  39. package/src/mcp/mcp-client.js +907 -530
  40. package/src/mcp/modern.js +372 -0
  41. package/src/mcp/protocol.js +468 -466
  42. package/src/mcp/server.js +2688 -1405
  43. package/src/packages/packager.js +715 -715
  44. package/src/tools/drift.js +138 -138
  45. package/src/tools/registry.js +513 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1174 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1348 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +112 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -216
@@ -1,171 +1,171 @@
1
- 'use strict';
2
-
3
- /**
4
- * logger.js — a toggleable JSONL activity/audit log for the gateway.
5
- *
6
- * DEFAULT OFF (privacy + lean). Nothing is written, and no files are created, unless
7
- * logging has been explicitly enabled via setConfig(). A MISSING config file means
8
- * disabled — the safe default — so a fresh checkout logs nothing.
9
- *
10
- * Design rules:
11
- * - Zero dependencies. Node built-ins only (fs, path). No transport, no SDK.
12
- * - Config is read FRESH on every call (no caching), so a toggle takes effect for the
13
- * very next event without a reconnect or restart.
14
- * - log() must NEVER throw. A logging failure (bad config, unwritable disk, full FS)
15
- * must never break a tool call or the gate. Everything is wrapped; failures are
16
- * swallowed silently.
17
- *
18
- * Paths are resolved against the repo root (two dirs up from src/core/). The configured
19
- * log `path` may be relative (resolved against root) or absolute.
20
- *
21
- * Config file: <root>/logs/log.config.json
22
- * { "enabled": false, "path": "logs/toolfunnel.log.jsonl" }
23
- *
24
- * CommonJS only.
25
- */
26
-
27
- const fs = require('node:fs');
28
- const path = require('node:path');
29
-
30
- /** The CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root — see
31
- * config-home.js). Logs + their toggle are user-state, so they live with the home. */
32
- const { resolveConfigHome } = require('./config-home');
33
- const ROOT = resolveConfigHome();
34
-
35
- /** The toggle/config file. NOT created until setConfig() writes it. */
36
- const CONFIG_PATH = path.join(ROOT, 'logs', 'log.config.json');
37
-
38
- /** Safe defaults — used whenever the config file is absent or unreadable. */
39
- const DEFAULT_ENABLED = false;
40
- const DEFAULT_PATH = 'logs/toolfunnel.log.jsonl';
41
-
42
- /**
43
- * Resolve a (possibly relative) log path against the repo root.
44
- * @param {string} p
45
- * @returns {string}
46
- */
47
- function resolveLogPath(p) {
48
- return path.isAbsolute(p) ? p : path.join(ROOT, p);
49
- }
50
-
51
- /**
52
- * getConfig — the resolved { enabled, path }.
53
- *
54
- * Reads the config file fresh. A missing/unreadable/malformed file resolves to the
55
- * safe defaults (disabled). Never throws.
56
- *
57
- * @returns {{ enabled: boolean, path: string }}
58
- */
59
- function getConfig() {
60
- try {
61
- const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
62
- const parsed = JSON.parse(raw);
63
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
64
- return { enabled: DEFAULT_ENABLED, path: DEFAULT_PATH };
65
- }
66
- return {
67
- enabled: parsed.enabled === true,
68
- path:
69
- typeof parsed.path === 'string' && parsed.path.length > 0
70
- ? parsed.path
71
- : DEFAULT_PATH,
72
- };
73
- } catch (_err) {
74
- // Missing file = disabled (the safe default); also covers unreadable/malformed.
75
- return { enabled: DEFAULT_ENABLED, path: DEFAULT_PATH };
76
- }
77
- }
78
-
79
- /**
80
- * log — append one JSONL record IF logging is enabled, else a silent no-op.
81
- *
82
- * The record is the event fields plus a logger-stamped ISO-8601 "ts" timestamp.
83
- * Resolves the configured path against root, mkdir -p its directory, and appends one
84
- * line with fs.appendFileSync. NEVER throws — any failure is swallowed.
85
- *
86
- * @param {object} event arbitrary serialisable fields (e.g. { type, tool, decision }).
87
- */
88
- function log(event) {
89
- try {
90
- const cfg = getConfig();
91
- if (!cfg.enabled) return; // default-off: no-op, no file created.
92
-
93
- const record = Object.assign(
94
- { ts: new Date().toISOString() },
95
- event && typeof event === 'object' && !Array.isArray(event) ? event : {}
96
- );
97
-
98
- const logPath = resolveLogPath(cfg.path);
99
- fs.mkdirSync(path.dirname(logPath), { recursive: true });
100
- fs.appendFileSync(logPath, JSON.stringify(record) + '\n');
101
- } catch (_err) {
102
- // Logging must NEVER break the caller. Swallow everything.
103
- }
104
- }
105
-
106
- /**
107
- * setConfig — atomically merge a patch into logs/log.config.json (temp + rename).
108
- *
109
- * Merges with the current resolved config so a partial patch (e.g. { enabled: true })
110
- * preserves the existing path. Creates the logs/ dir and the config file if absent —
111
- * this is the ONLY function that creates the config file.
112
- *
113
- * @param {{ enabled?: boolean, path?: string }} patch
114
- * @returns {{ enabled: boolean, path: string }} the merged, written config.
115
- */
116
- function setConfig(patch) {
117
- const current = getConfig();
118
- const p = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
119
-
120
- const next = {
121
- enabled: typeof p.enabled === 'boolean' ? p.enabled : current.enabled,
122
- path:
123
- typeof p.path === 'string' && p.path.length > 0 ? p.path : current.path,
124
- };
125
-
126
- fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
127
- // Atomic write: write a unique temp file, then rename over the target.
128
- const tmp = CONFIG_PATH + '.' + process.pid + '.' + Date.now() + '.tmp';
129
- fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
130
- fs.renameSync(tmp, CONFIG_PATH);
131
-
132
- return next;
133
- }
134
-
135
- /**
136
- * tail — return the last n parsed JSON lines from the configured log.
137
- *
138
- * Returns [] if the log is missing or unreadable. Unparseable lines are skipped. When
139
- * n is not a positive finite number, all lines are returned. Never throws.
140
- *
141
- * @param {number} [n] how many trailing lines to parse and return.
142
- * @returns {object[]}
143
- */
144
- function tail(n) {
145
- try {
146
- const cfg = getConfig();
147
- const logPath = resolveLogPath(cfg.path);
148
- const raw = fs.readFileSync(logPath, 'utf8');
149
- const lines = raw.split('\n').filter(function (l) {
150
- return l.length > 0;
151
- });
152
-
153
- const wanted =
154
- typeof n === 'number' && isFinite(n) && n > 0 ? Math.floor(n) : lines.length;
155
- const slice = lines.slice(-wanted);
156
-
157
- const out = [];
158
- for (let i = 0; i < slice.length; i++) {
159
- try {
160
- out.push(JSON.parse(slice[i]));
161
- } catch (_e) {
162
- // Skip a corrupt/partial line rather than failing the whole tail.
163
- }
164
- }
165
- return out;
166
- } catch (_err) {
167
- return [];
168
- }
169
- }
170
-
171
- module.exports = { log, setConfig, getConfig, tail };
1
+ 'use strict';
2
+
3
+ /**
4
+ * logger.js - a toggleable JSONL activity/audit log for the gateway.
5
+ *
6
+ * DEFAULT OFF (privacy + lean). Nothing is written, and no files are created, unless
7
+ * logging has been explicitly enabled via setConfig(). A MISSING config file means
8
+ * disabled - the safe default - so a fresh checkout logs nothing.
9
+ *
10
+ * Design rules:
11
+ * - Zero dependencies. Node built-ins only (fs, path). No transport, no SDK.
12
+ * - Config is read FRESH on every call (no caching), so a toggle takes effect for the
13
+ * very next event without a reconnect or restart.
14
+ * - log() must NEVER throw. A logging failure (bad config, unwritable disk, full FS)
15
+ * must never break a tool call or the gate. Everything is wrapped; failures are
16
+ * swallowed silently.
17
+ *
18
+ * Paths are resolved against the repo root (two dirs up from src/core/). The configured
19
+ * log `path` may be relative (resolved against root) or absolute.
20
+ *
21
+ * Config file: <root>/logs/log.config.json
22
+ * { "enabled": false, "path": "logs/toolfunnel.log.jsonl" }
23
+ *
24
+ * CommonJS only.
25
+ */
26
+
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+
30
+ /** The CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root - see
31
+ * config-home.js). Logs + their toggle are user-state, so they live with the home. */
32
+ const { resolveConfigHome } = require('./config-home');
33
+ const ROOT = resolveConfigHome();
34
+
35
+ /** The toggle/config file. NOT created until setConfig() writes it. */
36
+ const CONFIG_PATH = path.join(ROOT, 'logs', 'log.config.json');
37
+
38
+ /** Safe defaults - used whenever the config file is absent or unreadable. */
39
+ const DEFAULT_ENABLED = false;
40
+ const DEFAULT_PATH = 'logs/toolfunnel.log.jsonl';
41
+
42
+ /**
43
+ * Resolve a (possibly relative) log path against the repo root.
44
+ * @param {string} p
45
+ * @returns {string}
46
+ */
47
+ function resolveLogPath(p) {
48
+ return path.isAbsolute(p) ? p : path.join(ROOT, p);
49
+ }
50
+
51
+ /**
52
+ * getConfig - the resolved { enabled, path }.
53
+ *
54
+ * Reads the config file fresh. A missing/unreadable/malformed file resolves to the
55
+ * safe defaults (disabled). Never throws.
56
+ *
57
+ * @returns {{ enabled: boolean, path: string }}
58
+ */
59
+ function getConfig() {
60
+ try {
61
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
62
+ const parsed = JSON.parse(raw);
63
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
64
+ return { enabled: DEFAULT_ENABLED, path: DEFAULT_PATH };
65
+ }
66
+ return {
67
+ enabled: parsed.enabled === true,
68
+ path:
69
+ typeof parsed.path === 'string' && parsed.path.length > 0
70
+ ? parsed.path
71
+ : DEFAULT_PATH,
72
+ };
73
+ } catch (_err) {
74
+ // Missing file = disabled (the safe default); also covers unreadable/malformed.
75
+ return { enabled: DEFAULT_ENABLED, path: DEFAULT_PATH };
76
+ }
77
+ }
78
+
79
+ /**
80
+ * log - append one JSONL record IF logging is enabled, else a silent no-op.
81
+ *
82
+ * The record is the event fields plus a logger-stamped ISO-8601 "ts" timestamp.
83
+ * Resolves the configured path against root, mkdir -p its directory, and appends one
84
+ * line with fs.appendFileSync. NEVER throws - any failure is swallowed.
85
+ *
86
+ * @param {object} event arbitrary serialisable fields (e.g. { type, tool, decision }).
87
+ */
88
+ function log(event) {
89
+ try {
90
+ const cfg = getConfig();
91
+ if (!cfg.enabled) return; // default-off: no-op, no file created.
92
+
93
+ const record = Object.assign(
94
+ { ts: new Date().toISOString() },
95
+ event && typeof event === 'object' && !Array.isArray(event) ? event : {}
96
+ );
97
+
98
+ const logPath = resolveLogPath(cfg.path);
99
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
100
+ fs.appendFileSync(logPath, JSON.stringify(record) + '\n');
101
+ } catch (_err) {
102
+ // Logging must NEVER break the caller. Swallow everything.
103
+ }
104
+ }
105
+
106
+ /**
107
+ * setConfig - atomically merge a patch into logs/log.config.json (temp + rename).
108
+ *
109
+ * Merges with the current resolved config so a partial patch (e.g. { enabled: true })
110
+ * preserves the existing path. Creates the logs/ dir and the config file if absent -
111
+ * this is the ONLY function that creates the config file.
112
+ *
113
+ * @param {{ enabled?: boolean, path?: string }} patch
114
+ * @returns {{ enabled: boolean, path: string }} the merged, written config.
115
+ */
116
+ function setConfig(patch) {
117
+ const current = getConfig();
118
+ const p = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
119
+
120
+ const next = {
121
+ enabled: typeof p.enabled === 'boolean' ? p.enabled : current.enabled,
122
+ path:
123
+ typeof p.path === 'string' && p.path.length > 0 ? p.path : current.path,
124
+ };
125
+
126
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
127
+ // Atomic write: write a unique temp file, then rename over the target.
128
+ const tmp = CONFIG_PATH + '.' + process.pid + '.' + Date.now() + '.tmp';
129
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
130
+ fs.renameSync(tmp, CONFIG_PATH);
131
+
132
+ return next;
133
+ }
134
+
135
+ /**
136
+ * tail - return the last n parsed JSON lines from the configured log.
137
+ *
138
+ * Returns [] if the log is missing or unreadable. Unparseable lines are skipped. When
139
+ * n is not a positive finite number, all lines are returned. Never throws.
140
+ *
141
+ * @param {number} [n] how many trailing lines to parse and return.
142
+ * @returns {object[]}
143
+ */
144
+ function tail(n) {
145
+ try {
146
+ const cfg = getConfig();
147
+ const logPath = resolveLogPath(cfg.path);
148
+ const raw = fs.readFileSync(logPath, 'utf8');
149
+ const lines = raw.split('\n').filter(function (l) {
150
+ return l.length > 0;
151
+ });
152
+
153
+ const wanted =
154
+ typeof n === 'number' && isFinite(n) && n > 0 ? Math.floor(n) : lines.length;
155
+ const slice = lines.slice(-wanted);
156
+
157
+ const out = [];
158
+ for (let i = 0; i < slice.length; i++) {
159
+ try {
160
+ out.push(JSON.parse(slice[i]));
161
+ } catch (_e) {
162
+ // Skip a corrupt/partial line rather than failing the whole tail.
163
+ }
164
+ }
165
+ return out;
166
+ } catch (_err) {
167
+ return [];
168
+ }
169
+ }
170
+
171
+ module.exports = { log, setConfig, getConfig, tail };
@@ -1,51 +1,51 @@
1
- 'use strict';
2
-
3
- /**
4
- * matcher.js — does a hook's matcher string fire for a given tool? (HOOK_ENGINE.md §5)
5
- *
6
- * Mirrors Claude Code's matcher semantics:
7
- * - "" | undefined | "*" → always fire (wildcard).
8
- * - otherwise the matcher is a regex, anchored as a FULL match against toolName
9
- * (e.g. "Bash|Write|Edit").
10
- * - tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) pass
11
- * toolName == null/undefined → always fire (the matcher is ignored).
12
- *
13
- * CommonJS only. Zero host imports (core/ must run headless under node --test).
14
- */
15
-
16
- /**
17
- * @param {string|undefined|null} matcher the hook's matcher string.
18
- * @param {string|undefined|null} toolName the tool the model requested, or
19
- * null/undefined for events without a tool.
20
- * @returns {boolean} true if the hook should fire for this tool.
21
- */
22
- function matches(matcher, toolName) {
23
- // Wildcard / unset matcher → always fire, regardless of tool.
24
- if (matcher === undefined || matcher === null || matcher === '' || matcher === '*') {
25
- return true;
26
- }
27
-
28
- // Tool-less events: there is nothing to match against, so the matcher is
29
- // ignored and the hook always fires (§5). This is checked AFTER the wildcard
30
- // case so a "*" matcher is handled uniformly, and BEFORE we attempt a regex
31
- // (which would otherwise need a string to test).
32
- if (toolName === undefined || toolName === null) {
33
- return true;
34
- }
35
-
36
- // Anything else is a regex, anchored as a full match against toolName.
37
- // We wrap in ^(?:…)$ so alternations like "Bash|Write" mean "the whole tool
38
- // name is one of these", not "contains one of these".
39
- let re;
40
- try {
41
- re = new RegExp(`^(?:${matcher})$`);
42
- } catch (err) {
43
- // A malformed matcher must never throw out of the engine. Treat an
44
- // uncompilable pattern as "does not match" — fail closed, stay defensive.
45
- return false;
46
- }
47
-
48
- return re.test(toolName);
49
- }
50
-
51
- module.exports = { matches };
1
+ 'use strict';
2
+
3
+ /**
4
+ * matcher.js - does a hook's matcher string fire for a given tool? (HOOK_ENGINE.md §5)
5
+ *
6
+ * Mirrors Claude Code's matcher semantics:
7
+ * - "" | undefined | "*" -> always fire (wildcard).
8
+ * - otherwise the matcher is a regex, anchored as a FULL match against toolName
9
+ * (e.g. "Bash|Write|Edit").
10
+ * - tool-less events (SessionStart, UserPromptSubmit, Stop, PreCompact) pass
11
+ * toolName == null/undefined -> always fire (the matcher is ignored).
12
+ *
13
+ * CommonJS only. Zero host imports (core/ must run headless under node --test).
14
+ */
15
+
16
+ /**
17
+ * @param {string|undefined|null} matcher the hook's matcher string.
18
+ * @param {string|undefined|null} toolName the tool the model requested, or
19
+ * null/undefined for events without a tool.
20
+ * @returns {boolean} true if the hook should fire for this tool.
21
+ */
22
+ function matches(matcher, toolName) {
23
+ // Wildcard / unset matcher -> always fire, regardless of tool.
24
+ if (matcher === undefined || matcher === null || matcher === '' || matcher === '*') {
25
+ return true;
26
+ }
27
+
28
+ // Tool-less events: there is nothing to match against, so the matcher is
29
+ // ignored and the hook always fires (§5). This is checked AFTER the wildcard
30
+ // case so a "*" matcher is handled uniformly, and BEFORE we attempt a regex
31
+ // (which would otherwise need a string to test).
32
+ if (toolName === undefined || toolName === null) {
33
+ return true;
34
+ }
35
+
36
+ // Anything else is a regex, anchored as a full match against toolName.
37
+ // We wrap in ^(?:...)$ so alternations like "Bash|Write" mean "the whole tool
38
+ // name is one of these", not "contains one of these".
39
+ let re;
40
+ try {
41
+ re = new RegExp(`^(?:${matcher})$`);
42
+ } catch (err) {
43
+ // A malformed matcher must never throw out of the engine. Treat an
44
+ // uncompilable pattern as "does not match" - fail closed, stay defensive.
45
+ return false;
46
+ }
47
+
48
+ return re.test(toolName);
49
+ }
50
+
51
+ module.exports = { matches };
@@ -1,75 +1,75 @@
1
- 'use strict';
2
-
3
- /**
4
- * metrics.js — in-memory, per-process tool-call counters (lightweight observability).
5
- *
6
- * A privileged gateway should be able to answer "what has run through me, and how often did it
7
- * fail?" without standing up a metrics stack. This is the zero-dependency MVP of that: process-
8
- * lifetime counters incremented at the single tools/call chokepoint and surfaced via the HTTP
9
- * `/health` endpoint and the admin UI. It is NOT persistent (counts reset when the host restarts)
10
- * and NOT a substitute for the JSONL audit log (src/core/logger.js, which records individual events
11
- * to disk when enabled) — the two are complementary: the log is the durable per-event record, this
12
- * is the cheap live aggregate.
13
- *
14
- * Design rules (same discipline as logger.js):
15
- * - Zero dependencies. Plain objects + integers.
16
- * - record() / snapshot() NEVER throw — instrumentation must never break a tool call.
17
- * - In-memory only; no files, no timers, no network.
18
- *
19
- * CommonJS only.
20
- */
21
-
22
- const state = {
23
- startedAt: new Date().toISOString(),
24
- calls: 0,
25
- errors: 0,
26
- byTool: Object.create(null), // name -> { calls, errors }
27
- };
28
-
29
- /**
30
- * record — count one tools/call outcome. `ok:false` (an isError result — a tool failure OR a
31
- * PreToolUse denial, which both surface as an error envelope) increments the error counters.
32
- * @param {{ tool?:string, ok?:boolean }} ev
33
- */
34
- function record(ev) {
35
- try {
36
- const tool = ev && typeof ev.tool === 'string' && ev.tool.length > 0 ? ev.tool : 'unknown';
37
- const ok = !(ev && ev.ok === false);
38
- state.calls += 1;
39
- if (!ok) state.errors += 1;
40
- let t = state.byTool[tool];
41
- if (!t) {
42
- t = { calls: 0, errors: 0 };
43
- state.byTool[tool] = t;
44
- }
45
- t.calls += 1;
46
- if (!ok) t.errors += 1;
47
- } catch (_e) {
48
- /* instrumentation must never break the caller */
49
- }
50
- }
51
-
52
- /**
53
- * snapshot — a fresh, mutation-safe copy of the counters for /health + the UI. NEVER throws.
54
- * @returns {{ startedAt:string, calls:number, errors:number, byTool:Object<string,{calls:number,errors:number}> }}
55
- */
56
- function snapshot() {
57
- try {
58
- const byTool = {};
59
- for (const k of Object.keys(state.byTool)) {
60
- byTool[k] = { calls: state.byTool[k].calls, errors: state.byTool[k].errors };
61
- }
62
- return { startedAt: state.startedAt, calls: state.calls, errors: state.errors, byTool };
63
- } catch (_e) {
64
- return { startedAt: state.startedAt, calls: 0, errors: 0, byTool: {} };
65
- }
66
- }
67
-
68
- /** reset — zero the counters (tests). The startedAt stamp is left as the process start. */
69
- function reset() {
70
- state.calls = 0;
71
- state.errors = 0;
72
- state.byTool = Object.create(null);
73
- }
74
-
75
- module.exports = { record, snapshot, reset };
1
+ 'use strict';
2
+
3
+ /**
4
+ * metrics.js - in-memory, per-process tool-call counters (lightweight observability).
5
+ *
6
+ * A privileged gateway should be able to answer "what has run through me, and how often did it
7
+ * fail?" without standing up a metrics stack. This is the zero-dependency MVP of that: process-
8
+ * lifetime counters incremented at the single tools/call chokepoint and surfaced via the HTTP
9
+ * `/health` endpoint and the admin UI. It is NOT persistent (counts reset when the host restarts)
10
+ * and NOT a substitute for the JSONL audit log (src/core/logger.js, which records individual events
11
+ * to disk when enabled) - the two are complementary: the log is the durable per-event record, this
12
+ * is the cheap live aggregate.
13
+ *
14
+ * Design rules (same discipline as logger.js):
15
+ * - Zero dependencies. Plain objects + integers.
16
+ * - record() / snapshot() NEVER throw - instrumentation must never break a tool call.
17
+ * - In-memory only; no files, no timers, no network.
18
+ *
19
+ * CommonJS only.
20
+ */
21
+
22
+ const state = {
23
+ startedAt: new Date().toISOString(),
24
+ calls: 0,
25
+ errors: 0,
26
+ byTool: Object.create(null), // name -> { calls, errors }
27
+ };
28
+
29
+ /**
30
+ * record - count one tools/call outcome. `ok:false` (an isError result - a tool failure OR a
31
+ * PreToolUse denial, which both surface as an error envelope) increments the error counters.
32
+ * @param {{ tool?:string, ok?:boolean }} ev
33
+ */
34
+ function record(ev) {
35
+ try {
36
+ const tool = ev && typeof ev.tool === 'string' && ev.tool.length > 0 ? ev.tool : 'unknown';
37
+ const ok = !(ev && ev.ok === false);
38
+ state.calls += 1;
39
+ if (!ok) state.errors += 1;
40
+ let t = state.byTool[tool];
41
+ if (!t) {
42
+ t = { calls: 0, errors: 0 };
43
+ state.byTool[tool] = t;
44
+ }
45
+ t.calls += 1;
46
+ if (!ok) t.errors += 1;
47
+ } catch (_e) {
48
+ /* instrumentation must never break the caller */
49
+ }
50
+ }
51
+
52
+ /**
53
+ * snapshot - a fresh, mutation-safe copy of the counters for /health + the UI. NEVER throws.
54
+ * @returns {{ startedAt:string, calls:number, errors:number, byTool:Object<string,{calls:number,errors:number}> }}
55
+ */
56
+ function snapshot() {
57
+ try {
58
+ const byTool = {};
59
+ for (const k of Object.keys(state.byTool)) {
60
+ byTool[k] = { calls: state.byTool[k].calls, errors: state.byTool[k].errors };
61
+ }
62
+ return { startedAt: state.startedAt, calls: state.calls, errors: state.errors, byTool };
63
+ } catch (_e) {
64
+ return { startedAt: state.startedAt, calls: 0, errors: 0, byTool: {} };
65
+ }
66
+ }
67
+
68
+ /** reset - zero the counters (tests). The startedAt stamp is left as the process start. */
69
+ function reset() {
70
+ state.calls = 0;
71
+ state.errors = 0;
72
+ state.byTool = Object.create(null);
73
+ }
74
+
75
+ module.exports = { record, snapshot, reset };