toolfunnel 0.7.1 → 0.8.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.
@@ -19,11 +19,19 @@
19
19
  * CommonJS only.
20
20
  */
21
21
 
22
+ // Distinct byTool entries are CAPPED: the key is the tools/call name exactly as the client sent it,
23
+ // including names that never existed, so an unbounded map let a client spraying fresh names grow
24
+ // the gateway's memory for the life of the process. Names seen before the cap keep their own
25
+ // counters; everything after lands in one overflow bucket. Totals stay exact either way.
26
+ const MAX_DISTINCT_TOOLS = 1000;
27
+ const OVERFLOW_KEY = '(other)';
28
+
22
29
  const state = {
23
30
  startedAt: new Date().toISOString(),
24
31
  calls: 0,
25
32
  errors: 0,
26
33
  byTool: Object.create(null), // name -> { calls, errors }
34
+ distinct: 0, // entries in byTool (the overflow bucket counts as one)
27
35
  };
28
36
 
29
37
  /**
@@ -38,9 +46,13 @@ function record(ev) {
38
46
  state.calls += 1;
39
47
  if (!ok) state.errors += 1;
40
48
  let t = state.byTool[tool];
41
- if (!t) {
49
+ if (!t && state.distinct >= MAX_DISTINCT_TOOLS && tool !== OVERFLOW_KEY) {
50
+ t = state.byTool[OVERFLOW_KEY]; // at the cap: an unseen name counts under the overflow bucket
51
+ if (!t) { t = { calls: 0, errors: 0 }; state.byTool[OVERFLOW_KEY] = t; state.distinct += 1; }
52
+ } else if (!t) {
42
53
  t = { calls: 0, errors: 0 };
43
54
  state.byTool[tool] = t;
55
+ state.distinct += 1;
44
56
  }
45
57
  t.calls += 1;
46
58
  if (!ok) t.errors += 1;
@@ -55,7 +67,9 @@ function record(ev) {
55
67
  */
56
68
  function snapshot() {
57
69
  try {
58
- const byTool = {};
70
+ // Null-prototype like the source: into a plain {} a key of "__proto__" sets the PROTOTYPE
71
+ // instead of a property, so that tool's counter silently vanished from /health and the UI.
72
+ const byTool = Object.create(null);
59
73
  for (const k of Object.keys(state.byTool)) {
60
74
  byTool[k] = { calls: state.byTool[k].calls, errors: state.byTool[k].errors };
61
75
  }
@@ -70,6 +84,7 @@ function reset() {
70
84
  state.calls = 0;
71
85
  state.errors = 0;
72
86
  state.byTool = Object.create(null);
87
+ state.distinct = 0;
73
88
  }
74
89
 
75
90
  module.exports = { record, snapshot, reset };
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+ /**
3
+ * output-cap.js - keep a captured stream within a byte cap.
4
+ *
5
+ * A child's output arrives in chunks (64 KiB pipe reads on Windows). Appending a whole chunk and checking the cap
6
+ * afterwards lets the captured text run past the cap by up to one chunk. takeWithinCap returns the part of a chunk
7
+ * that still fits, ending on a whole character so a multi-byte UTF-8 character is never split.
8
+ */
9
+
10
+ /**
11
+ * @param {string} text the decoded chunk
12
+ * @param {number} used bytes already captured
13
+ * @param {number} max the cap in bytes
14
+ * @returns {string} the leading part of text whose UTF-8 size fits in max - used
15
+ */
16
+ function takeWithinCap(text, used, max) {
17
+ const room = max - used;
18
+ if (room <= 0) return '';
19
+ if (Buffer.byteLength(text, 'utf8') <= room) return text;
20
+ let bytes = 0;
21
+ let end = 0;
22
+ for (const ch of text) { // by code point, so a surrogate pair is never split either
23
+ const n = Buffer.byteLength(ch, 'utf8');
24
+ if (bytes + n > room) break;
25
+ bytes += n;
26
+ end += ch.length;
27
+ }
28
+ return text.slice(0, end);
29
+ }
30
+
31
+ module.exports = { takeWithinCap };
@@ -1,13 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * rename-migration.js - keep gates and per-tool state attached across a SURFACED-NAME change
5
- * (0.7.0; the rename-safety.test.js invariants are the acceptance).
4
+ * rename-migration.js - keep gates and per-tool state attached across a SURFACED-NAME change.
6
5
  *
7
6
  * A tool's SURFACED name is the string the PreToolUse gate matches and the tools.state.json
8
- * key is written against. 0.6.0 let that name change (an expose `as` edit, an enabled flip, a
9
- * local display-name edit) while the gate matcher and the state key silently kept the OLD
10
- * string - a deny gate stopped firing and a disabled tool resurrected. The policy here,
7
+ * key is written against. When that name changes (an expose `as` edit, an enabled flip, a
8
+ * local display-name edit) and the gate matcher and the state key keep the OLD string, a deny
9
+ * gate stops firing and a disabled tool comes back. The policy here,
11
10
  * MATCHER-MIGRATE-ON-RENAME:
12
11
  *
13
12
  * - A WILDCARD matcher ('', '*', unset) fires for every tool name - it already covers the
@@ -36,21 +35,9 @@ function escapeRegex(str) {
36
35
  return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
37
36
  }
38
37
 
39
- /** Copied exactly from registry.js::atomicWriteJson - crash leaves old file or new, never half. */
40
- function atomicWriteJson(targetPath, obj) {
41
- const dir = path.dirname(targetPath);
42
- const base = path.basename(targetPath);
43
- const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
44
- const data = JSON.stringify(obj, null, 2) + '\n';
45
- const fd = fs.openSync(tmp, 'w');
46
- try {
47
- fs.writeSync(fd, data, 0, 'utf8');
48
- fs.fsyncSync(fd);
49
- } finally {
50
- fs.closeSync(fd);
51
- }
52
- fs.renameSync(tmp, targetPath);
53
- }
38
+ // THE atomic writer (temp + fsync + rename, failure-path unlink, stale sweep) - shared by every store.
39
+ // This file's own copy had neither the unlink nor the sweep.
40
+ const { atomicWriteJson, atomicWriteText } = require('./atomic-write');
54
41
 
55
42
  function readJsonIfPresent(p) {
56
43
  try {
@@ -160,4 +147,32 @@ function migrateForStoreFile(storeFilePath, oldName, newName, opts) {
160
147
  });
161
148
  }
162
149
 
163
- module.exports = { migrateSurfacedName, migrateForStoreFile, escapeRegex };
150
+ /**
151
+ * The sibling config files a migration may write, derived as migrateForStoreFile does (the store's
152
+ * grandparent is the config-home root). Exported so a caller can snapshot them BEFORE a migration and
153
+ * restore them if the write that follows fails - re-running a migration in reverse is not an unwind:
154
+ * it re-validates, and a pattern hook that matches the NEW name refuses it, leaving the manifest
155
+ * naming a surface the store does not (rename-rollback.test.js, case A2).
156
+ */
157
+ function snapshotForStoreFile(storeFilePath) {
158
+ const root = path.dirname(path.dirname(storeFilePath));
159
+ const files = [path.join(root, 'hooks', 'hooks.manifest.json'), path.join(root, 'tools', 'tools.state.json')];
160
+ const snap = [];
161
+ for (const f of files) {
162
+ let bytes = null;
163
+ try { bytes = fs.readFileSync(f); } catch (_e) { bytes = null; } // absent stays absent
164
+ snap.push({ path: f, bytes });
165
+ }
166
+ return snap;
167
+ }
168
+
169
+ /** Put the snapshotted bytes back exactly (absent files are removed again). Best-effort, never throws. */
170
+ function restoreSnapshot(snap) {
171
+ for (const { path: f, bytes } of snap || []) {
172
+ try {
173
+ if (bytes == null) { if (fs.existsSync(f)) fs.unlinkSync(f); } else { atomicWriteText(f, bytes); }
174
+ } catch (_e) { /* best-effort - the caller's own error is the story */ }
175
+ }
176
+ }
177
+
178
+ module.exports = { migrateSurfacedName, migrateForStoreFile, escapeRegex, snapshotForStoreFile, restoreSnapshot };
@@ -88,7 +88,10 @@ Two protocols:
88
88
  as the result text with `isError: true`.
89
89
  - any other code → non-blocking error (stderr captured, loop continues; never throws).
90
90
 
91
- **B) JSON (advanced)** - honored **only on exit 0**, when stdout parses as a JSON object:
91
+ **B) JSON (advanced)** - honored **only on exit 0**, when stdout is a JSON object carrying protocol
92
+ keys - or ends with one on its own line after other output (a printed startup line is fine). A
93
+ decision that cannot be read (truncated, followed by stray text) **denies**; a printed object that
94
+ is not a decision (a diagnostic dump, a JSON log line) is ignored:
92
95
  ```json
93
96
  {
94
97
  "continue": true,
@@ -56,7 +56,7 @@ Field-by-field - these names are fixed (registry.js reads exactly these):
56
56
  |---|---|---|
57
57
  | `id` | string | Stable unique key, kebab-case. `toolfunnel_run_tool({ name })` and `registry.run(id, args)` look up by this. Never reuse an existing `id`. |
58
58
  | `name` | string | Human/model-facing display name. Shown in the manager UI. |
59
- | `summary` | string | One line. This is ALL that `toolfunnel_list_tools` returns for the tool - keep it tight and discriminating so the model can pick the right tool from the brief alone. |
59
+ | `summary` | string | One line. This is ALL that `toolfunnel_list_tools` returns for the tool - keep it tight and discriminating so the model can pick the right tool from the brief alone. Name every mode the tool has (`encode` / `decode`, `sha256` / `md5`): the listing's filter searches the name and summary only, so a mode that lives only in `instructions` or the schema is never found. |
60
60
  | `category` | string | Grouping label (e.g. `demo`, `memory`, `messaging`, `media`). Used by `toolfunnel_list_tools({ category })` to filter. |
61
61
  | `instructions` | string | Full usage doc returned by `toolfunnel_tool_instructions({ name })`. Describe the args object, what comes back, and any safety caveats. This is the long-tail payload - be complete here, terse in `summary`. |
62
62
  | `inputSchema` | object (optional) | A JSON Schema for the tool's args. **This is what makes the tool a first-class MCP citizen when promoted hot:** the top-level `tools/list` advertises it VERBATIM, so a connected client knows the exact arg shape without reading `instructions`. Omit it and a hot-promoted tool falls back to a free-form `{ "type": "object" }` (callable, but the client gets no arg hints). Must be an object - the register rejects any other shape at authoring time. |
@@ -9,7 +9,7 @@ self-documented, a package is just a folder that lists its units and carries the
9
9
 
10
10
  ## 0. The WHOLE-SETUP path - `tf_pack` (start here)
11
11
 
12
- Since 0.4.0 the fastest way to ship is not per-unit packaging but the **whole setup**: your entire
12
+ The fastest way to ship is not per-unit packaging but the **whole setup**: your entire
13
13
  config home (tools + scripts, hot/hidden curation, upstream references, policy hooks, identity) is
14
14
  already the product. One call packages it into a deployment-ready artifact under `<home>/dist/`:
15
15