@zerowidth/workbench-sdk 2.0.0-alpha.0 → 2.1.1

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 (42) hide show
  1. package/nodes/combine-document-chunks/combine-document-chunks.config.json +8 -0
  2. package/nodes/combine-document-chunks/combine-document-chunks.process.js +9 -2
  3. package/nodes/describe-tables/describe-tables.config.json +59 -0
  4. package/nodes/describe-tables/describe-tables.process.js +40 -0
  5. package/nodes/expand-chunk-context/expand-chunk-context.config.json +87 -0
  6. package/nodes/expand-chunk-context/expand-chunk-context.process.js +34 -0
  7. package/nodes/find-entity-path/find-entity-path.config.json +73 -0
  8. package/nodes/find-entity-path/find-entity-path.process.js +51 -0
  9. package/nodes/firecrawl-search/firecrawl-search.config.json +46 -0
  10. package/nodes/firecrawl-search/firecrawl-search.process.js +54 -0
  11. package/nodes/firecrawl-search/firecrawl-search.tests.json +9 -0
  12. package/nodes/get-chunk-by-index/get-chunk-by-index.config.json +8 -0
  13. package/nodes/get-chunk-by-index/get-chunk-by-index.process.js +9 -2
  14. package/nodes/get-entity-neighbors/get-entity-neighbors.config.json +81 -0
  15. package/nodes/get-entity-neighbors/get-entity-neighbors.process.js +51 -0
  16. package/nodes/keyword-search/keyword-search.config.json +67 -0
  17. package/nodes/keyword-search/keyword-search.process.js +45 -0
  18. package/nodes/knowledge-base/knowledge-base.config.json +32 -0
  19. package/nodes/knowledge-base/knowledge-base.process.js +15 -0
  20. package/nodes/list-documents/list-documents.config.json +60 -0
  21. package/nodes/list-documents/list-documents.process.js +25 -0
  22. package/nodes/list-entities/list-entities.config.json +74 -0
  23. package/nodes/list-entities/list-entities.process.js +25 -0
  24. package/nodes/query-knowledge-base/query-knowledge-base.config.json +14 -7
  25. package/nodes/query-knowledge-base/query-knowledge-base.process.js +25 -5
  26. package/nodes/read-chunks/read-chunks.config.json +79 -0
  27. package/nodes/read-chunks/read-chunks.process.js +49 -0
  28. package/nodes/remote-mcp-tool/remote-mcp-tool.config.json +1 -0
  29. package/nodes/search-internet/search-internet.config.json +7 -7
  30. package/nodes/semantic-search/semantic-search.config.json +9 -1
  31. package/nodes/semantic-search/semantic-search.process.js +41 -18
  32. package/nodes/tool/tool.config.json +1 -0
  33. package/package.json +12 -7
  34. package/src/index.js +81 -11
  35. package/src/integrations/firecrawl.js +71 -0
  36. package/src/integrations/knowledge-base-interface.js +49 -0
  37. package/src/integrations/sqlite.js +535 -83
  38. package/src/types/knowledge_base.json +9 -0
  39. package/src/utilities/loaders.js +67 -11
  40. package/src/utilities/typers.js +15 -17
  41. package/src/utilities/validators.js +16 -1
  42. package/types/knowledge_base.json +9 -0
@@ -1,44 +1,67 @@
1
1
  export default async ({inputs, settings, config, nodeConfig}) => {
2
2
  try {
3
- // Get knowledge base and OpenAI integrations
4
- const knowledgeBase = config.integrations?.knowledgeBase || config.integrations?.sqlite;
5
- const openai = config.integrations?.openai;
6
-
3
+ // Resolve which knowledge base to search. Node-level binding: a
4
+ // `knowledge_base` handle wired in from a Knowledge Base node names a
5
+ // specific KB by uuid, resolved to a per-KB integration the host keyed
6
+ // as config.integrations.knowledgeBases[uuid]. Falls back to the
7
+ // flow-global KB (config.integrations.knowledgeBase / .sqlite) so
8
+ // existing single-KB flows keep working. See ADR 0023.
9
+ const kbRef = inputs.knowledge_base;
10
+ const kbByUuid =
11
+ kbRef && kbRef.uuid
12
+ ? config.integrations?.[`knowledgeBase:${kbRef.uuid}`]
13
+ : null;
14
+ const knowledgeBase =
15
+ kbByUuid ||
16
+ config.integrations?.knowledgeBase ||
17
+ config.integrations?.sqlite;
18
+ const openrouter = config.integrations?.openrouter;
19
+
7
20
  if (!knowledgeBase) {
8
21
  throw new Error("Knowledge base integration not found. Make sure a knowledge database is available.");
9
22
  }
10
-
11
- if (!openai) {
12
- throw new Error("OpenAI integration not found. Semantic search requires OpenAI API key for embeddings.");
23
+
24
+ if (!openrouter) {
25
+ throw new Error("OpenRouter integration not found. Semantic search requires an OpenRouter API key for query embeddings.");
13
26
  }
14
27
 
15
- const {
16
- query,
17
- limit = 10,
18
- similarity_threshold = 0.7,
19
- document_id = null
28
+ const {
29
+ query,
30
+ limit = 10,
31
+ // Default 0 (matches the node config) — return matches ranked by
32
+ // similarity rather than silently filtering everything below 0.7.
33
+ similarity_threshold = 0,
34
+ document_id = null
20
35
  } = inputs;
21
-
36
+
22
37
  const { embedding_model = null } = settings;
23
38
 
24
39
  if (!query || typeof query !== 'string') {
25
40
  throw new Error("Query is required and must be a string");
26
41
  }
27
42
 
28
- // Get the embedding model to use
43
+ // Embedding model: node-setting override, else the model the KB was
44
+ // indexed with (KB-owned — query + index MUST agree so semanticSearch
45
+ // can filter `embedding_model = ?`). OpenRouter model ids are
46
+ // namespaced, e.g. "openai/text-embedding-3-small".
29
47
  let modelToUse = embedding_model;
30
48
  if (!modelToUse) {
31
49
  try {
32
50
  modelToUse = await knowledgeBase.getEmbeddingModel();
33
51
  } catch (error) {
34
52
  console.warn('[WARN] Failed to get embedding model from knowledge base, using default:', error.message);
35
- modelToUse = 'text-embedding-3-small';
53
+ modelToUse = 'openai/text-embedding-3-small';
36
54
  }
37
55
  }
38
56
 
39
- // Create embedding for the query
40
- const embeddingResponse = await openai.createEmbedding(query, modelToUse);
41
- const queryEmbedding = embeddingResponse.data[0].embedding;
57
+ // Embed the query via OpenRouter. Returns { embedding, embeddings,
58
+ // dimensions, model, usage, cost_total?, cost_itemized? }.
59
+ const embeddingResponse = await openrouter.createEmbedding(
60
+ { model: modelToUse, input: query },
61
+ nodeConfig,
62
+ config
63
+ );
64
+ const queryEmbedding = embeddingResponse.embedding;
42
65
 
43
66
  // Perform semantic search
44
67
  const searchOptions = {
@@ -6,6 +6,7 @@
6
6
  "category": "messages",
7
7
  "custom_render_height": 200,
8
8
  "is_constant": true,
9
+ "is_plugin": true,
9
10
  "is_resizable": true,
10
11
  "inputs": [
11
12
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerowidth/workbench-sdk",
3
- "version": "2.0.0-alpha.0",
3
+ "version": "2.1.1",
4
4
  "dependencies": {
5
5
  "adm-zip": "^0.5.16",
6
6
  "ajv": "^8.17.1",
@@ -8,17 +8,21 @@
8
8
  "js-tiktoken": "^1.0.21",
9
9
  "openai": "^4.92.1",
10
10
  "sqlite-vec": "^0.1.7-alpha.2",
11
- "sqlite3": "^5.1.7",
12
11
  "tiktoken": "^1.0.22",
13
12
  "uuid": "^11.1.0"
14
13
  },
15
14
  "repository": "https://github.com/zerowidth-ai/workbench-sdk",
16
- "description": "Workbench SDK — execute Workbench-authored flows in production. Successor to the zv1 package; see https://zerowidth.ai for the design surface.",
15
+ "description": "Workbench SDK \u2014 execute Workbench-authored flows in production. Successor to the zv1 package; see https://zerowidth.ai for the design surface.",
17
16
  "main": "./src/index.js",
18
- "files": ["src/", "nodes/", "types/", "LICENSE", "README.md"],
17
+ "files": [
18
+ "src/",
19
+ "nodes/",
20
+ "types/",
21
+ "LICENSE",
22
+ "README.md"
23
+ ],
19
24
  "publishConfig": {
20
- "access": "public",
21
- "tag": "alpha"
25
+ "access": "public"
22
26
  },
23
27
  "engines": {
24
28
  "node": ">=20.0.0"
@@ -29,7 +33,8 @@
29
33
  "scripts": {
30
34
  "test-nodes": "node tests/test.all-nodes.js",
31
35
  "test-flows": "node tests/test.flows.js",
32
- "test-flow": "node tests/test.flows.js"
36
+ "test-flow": "node tests/test.flows.js",
37
+ "test-kb": "node tests/test.kb-search.js && node tests/test.kb-graph.js"
33
38
  },
34
39
  "author": "Peter Binggeser @ ZeroWidth, LLC",
35
40
  "license": "Apache-2.0",
package/src/index.js CHANGED
@@ -364,6 +364,21 @@ export default class Workbench {
364
364
  id: node.id
365
365
  };
366
366
 
367
+ // Config-only nodes (remote-mcp-tool) have no process function —
368
+ // they exist for the plugin loop, not direct execution. If one
369
+ // lands in the queue anyway (e.g. a dangling MCP node with no
370
+ // plugin link), no-op instead of crashing the whole run.
371
+ if (typeof nodeDefinition.process !== 'function') {
372
+ this.logDebug(`Node [${node.id}] (${node.type}) is config-only; skipping direct execution.`);
373
+ const endDate = new Date();
374
+ timelineEntry.outputs = {};
375
+ timelineEntry.endTime = endDate.toISOString();
376
+ timelineEntry.durationMs = endDate - startDate;
377
+ timelineEntry.status = 'success';
378
+ this.timeline.push(timelineEntry);
379
+ return {};
380
+ }
381
+
367
382
  const outputs = await this._raceAbort(
368
383
  nodeDefinition.process({inputs, settings, config: this.config, nodeConfig})
369
384
  );
@@ -1077,7 +1092,21 @@ export default class Workbench {
1077
1092
  await this.config.integrations.sqlite.disconnect();
1078
1093
  delete this.config.integrations.sqlite;
1079
1094
  }
1080
-
1095
+
1096
+ // Clean up node-level knowledge base integrations (keyed knowledgeBase:<uuid>).
1097
+ if (this.config.integrations) {
1098
+ for (const key of Object.keys(this.config.integrations)) {
1099
+ if (!key.startsWith('knowledgeBase:')) continue;
1100
+ this.logDebug(`Cleaning up node-level knowledge base integration ${key}...`);
1101
+ try {
1102
+ await this.config.integrations[key].disconnect();
1103
+ } catch (err) {
1104
+ this.logDebug(`Failed to disconnect ${key}: ${err.message}`);
1105
+ }
1106
+ delete this.config.integrations[key];
1107
+ }
1108
+ }
1109
+
1081
1110
  // Clean up any imported engines that were created
1082
1111
  // These are stored in the cache when import nodes are processed
1083
1112
  const rawStore = this.cache.getRawStore();
@@ -2337,10 +2366,13 @@ export default class Workbench {
2337
2366
  }
2338
2367
  }
2339
2368
  }
2340
- // Prepare the tool call message for the next LLM call
2369
+ // Prepare the tool call message for the next LLM call.
2370
+ // Preserve any text the model emitted alongside the tool calls —
2371
+ // nulling it here loses it from both the next round's context and
2372
+ // the final conversation output.
2341
2373
  toolCallMessage = {
2342
2374
  role: "assistant",
2343
- content: null,
2375
+ content: llmResult.content ?? null,
2344
2376
  tool_calls: llmResult.tool_calls
2345
2377
  };
2346
2378
  toolCallCount++;
@@ -2464,6 +2496,15 @@ export default class Workbench {
2464
2496
  * @returns {boolean} True if the node is a plugin or macro
2465
2497
  */
2466
2498
  isLocalNodePlugin(node) {
2499
+ // Remote MCP tool nodes and manual tool nodes carry is_plugin —
2500
+ // the flag drives shared plugin BEHAVIOR (entry-node exclusion,
2501
+ // propagation skip, the canvas's plugin drag-and-dock UX) — but
2502
+ // neither is a LOCAL plugin: MCP tools load their schemas from
2503
+ // the remote server (isRemoteMCPTool branch) and manual tools
2504
+ // are caller-executed pass-throughs (isManualToolNode branch).
2505
+ // Classifying either as local would misroute them in the plugin
2506
+ // loop (bogus single-tool schema / unwanted process execution).
2507
+ if (isRemoteMCPTool(node) || isManualToolNode(node)) return false;
2467
2508
  const thisNodeConfig = this.nodes[node.type]?.config || {};
2468
2509
  return thisNodeConfig.is_plugin || thisNodeConfig.is_macro;
2469
2510
  }
@@ -2879,21 +2920,44 @@ export default class Workbench {
2879
2920
  * @private
2880
2921
  */
2881
2922
  _trackKnowledgeFiles() {
2882
- // Track main flow's knowledge base file
2883
- if (this.flow.knowledgeDbPath) {
2884
- this.trackKnowledgeFile(this.flow.knowledgeDbPath);
2885
- }
2923
+ // Host-provided knowledge db paths are HOST-OWNED: the engine must
2924
+ // never delete them unless the host explicitly opts in (hosted
2925
+ // runners that hand the engine per-run temp copies set
2926
+ // config.knowledgeBase.cleanupDbFiles = true). The engine's own
2927
+ // archive extractions live under ./.temp and are always cleaned.
2928
+ const optIn = this.config?.knowledgeBase?.cleanupDbFiles === true;
2929
+ const track = (filePath) => {
2930
+ if (!filePath) return;
2931
+ if (optIn || this._isEngineTempPath(filePath)) {
2932
+ this.trackKnowledgeFile(filePath);
2933
+ }
2934
+ };
2935
+
2936
+ track(this.flow.knowledgeDbPath);
2886
2937
 
2887
- // Track import knowledge base files
2888
2938
  if (this.flow.imports && Array.isArray(this.flow.imports)) {
2889
2939
  for (const importDef of this.flow.imports) {
2890
- if (importDef.knowledgeDbPath) {
2891
- this.trackKnowledgeFile(importDef.knowledgeDbPath);
2892
- }
2940
+ track(importDef.knowledgeDbPath);
2941
+ }
2942
+ }
2943
+
2944
+ if (this.flow.knowledgeDbPaths && typeof this.flow.knowledgeDbPaths === 'object') {
2945
+ for (const dbPath of Object.values(this.flow.knowledgeDbPaths)) {
2946
+ track(dbPath);
2893
2947
  }
2894
2948
  }
2895
2949
  }
2896
2950
 
2951
+ /** Paths inside the engine's own ./.temp extraction dir. @private */
2952
+ _isEngineTempPath(filePath) {
2953
+ try {
2954
+ const tempDir = path.resolve(process.cwd(), '.temp') + path.sep;
2955
+ return path.resolve(filePath).startsWith(tempDir);
2956
+ } catch {
2957
+ return false;
2958
+ }
2959
+ }
2960
+
2897
2961
  /**
2898
2962
  * Clean up temporary knowledge base files for this specific engine instance
2899
2963
  * @private
@@ -2996,3 +3060,9 @@ export default class Workbench {
2996
3060
  }
2997
3061
  }
2998
3062
  }
3063
+
3064
+ // Base class for bring-your-own knowledge bases: implement its methods
3065
+ // over your own store (SQL database, vector store, HTTP service) and
3066
+ // pass an instance via `config.knowledgeBase.instance` (flow-global)
3067
+ // or `config.knowledgeBase.instances[kbUuid]` (per Knowledge Base node).
3068
+ export { KnowledgeBaseInterface } from './integrations/knowledge-base-interface.js';
@@ -82,6 +82,77 @@ export default class FirecrawlIntegration {
82
82
  }
83
83
  }
84
84
 
85
+ /**
86
+ * Search the web with the Firecrawl Search API
87
+ * @param {Object} params - Search parameters (query, limit, ...)
88
+ * @returns {Promise<Object>} Search response
89
+ */
90
+ async search(params = {}) {
91
+ try {
92
+ const url = `${this.options.baseURL}/search`;
93
+
94
+ // Remove any params that are null, undefined, or empty strings
95
+ Object.keys(params).forEach(key => {
96
+ if (params[key] === '') {
97
+ delete params[key];
98
+ }
99
+ if (params[key] === null) {
100
+ delete params[key];
101
+ }
102
+ if (params[key] === undefined) {
103
+ delete params[key];
104
+ }
105
+ if (Array.isArray(params[key]) && params[key].length === 0) {
106
+ delete params[key];
107
+ }
108
+ });
109
+
110
+ const requestHeaders = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKey}` };
111
+ const startTime = Date.now();
112
+
113
+ const response = await axios({
114
+ url: url,
115
+ method: 'POST',
116
+ headers: requestHeaders,
117
+ data: params,
118
+ timeout: this.options.timeout
119
+ });
120
+
121
+ await emitAPICallEvent(this._engineConfig, {
122
+ timestamp: startTime, integration: 'firecrawl', nodeId: null, nodeType: null,
123
+ request: { method: 'POST', url, headers: requestHeaders, body: params },
124
+ response: { status: response.status, statusText: response.statusText },
125
+ duration: Date.now() - startTime, error: null
126
+ });
127
+
128
+ if (response.status >= 400) {
129
+ throw new Error(`Firecrawl API error: ${response.status} - ${response.data?.error || response.statusText}`);
130
+ }
131
+
132
+ return response.data;
133
+
134
+ } catch (error) {
135
+ // Note: emitAPICallEvent for error case is handled by the catch in the caller
136
+ // since errors from axios.post throw before we can capture the response
137
+ if (error.response) {
138
+ const status = error.response.status;
139
+ const statusText = error.response.statusText;
140
+ const responseData = error.response.data;
141
+
142
+ let errorMessage = `Firecrawl API Error (${status} ${statusText})`;
143
+ if (responseData?.error) {
144
+ errorMessage += `: ${responseData.error}`;
145
+ }
146
+
147
+ throw new Error(errorMessage);
148
+ } else if (error.request) {
149
+ throw new Error('Firecrawl API Error: No response received');
150
+ } else {
151
+ throw new Error(`Firecrawl API Error: ${error.message}`);
152
+ }
153
+ }
154
+ }
155
+
85
156
  /**
86
157
  * Helper function to convert comma-separated string to array
87
158
  * @param {string|Array} input - String or array input
@@ -116,4 +116,53 @@ export class KnowledgeBaseInterface {
116
116
  async disconnect() {
117
117
  throw new Error('disconnect() method must be implemented by knowledge base integration');
118
118
  }
119
+
120
+ /**
121
+ * Keyword (exact-match) search over chunk content.
122
+ * @param {string} query @param {Object} options - { limit, document_id }
123
+ * @returns {Promise<Array>} chunk-shaped rows
124
+ */
125
+ async keywordSearch(query, options = {}) {
126
+ throw new Error('keywordSearch() method must be implemented by knowledge base integration');
127
+ }
128
+
129
+ /** List documents. @param {Object} options - { limit, offset } */
130
+ async listDocuments(options = {}) {
131
+ throw new Error('listDocuments() method must be implemented by knowledge base integration');
132
+ }
133
+
134
+ /** Page one document's chunks. @param {Object} options - { startIndex, limit } */
135
+ async getChunks(documentId, options = {}) {
136
+ throw new Error('getChunks() method must be implemented by knowledge base integration');
137
+ }
138
+
139
+ /** One chunk plus its neighbors. @param {Object} options - { before, after } */
140
+ async getChunkWindow(documentId, chunkIndex, options = {}) {
141
+ throw new Error('getChunkWindow() method must be implemented by knowledge base integration');
142
+ }
143
+
144
+ /** Tabular registry: table schemas + sample rows. @param {Object} options - { sampleLimit } */
145
+ async listTables(options = {}) {
146
+ throw new Error('listTables() method must be implemented by knowledge base integration');
147
+ }
148
+
149
+ /** Graph: entities, most-connected first. @param {Object} options - { limit, offset, type, search } */
150
+ async listEntities(options = {}) {
151
+ throw new Error('listEntities() method must be implemented by knowledge base integration');
152
+ }
153
+
154
+ /** Graph: resolve an entity by id or case-insensitive name. */
155
+ async getEntity(ref) {
156
+ throw new Error('getEntity() method must be implemented by knowledge base integration');
157
+ }
158
+
159
+ /** Graph: relations touching an entity. @param {Object} options - { direction, relation_type, limit } */
160
+ async getNeighbors(entityRef, options = {}) {
161
+ throw new Error('getNeighbors() method must be implemented by knowledge base integration');
162
+ }
163
+
164
+ /** Graph: shortest relation chain between two entities. @param {Object} options - { max_depth } */
165
+ async findPath(fromRef, toRef, options = {}) {
166
+ throw new Error('findPath() method must be implemented by knowledge base integration');
167
+ }
119
168
  }