flownav 0.1.3 → 0.2.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.
- package/README.md +47 -11
- package/dist/cli.js +29 -0
- package/dist/install.js +1 -0
- package/dist/mcp.js +11 -7
- package/dist/read.js +277 -0
- package/dist/stats.js +64 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -173,7 +173,7 @@ $ flownav check
|
|
|
173
173
|
flownav install claude --mcp # writes .claude/skills/flownav/SKILL.md and .mcp.json
|
|
174
174
|
```
|
|
175
175
|
|
|
176
|
-
This installs a skill that tells the agent to query flownav before reading, renaming or deleting nodes, and registers an MCP server (`flownav serve`, stdio) exposing `flownav_scan`, `explain`, `neighbors`, `path`, `find`, `affected`, `callers`, `calls`, `uses`, `check` and `report`. The server reads `.flownav/graph.json` from the working directory (or `--graph <file>`) and reloads it when the file changes. The skill works without MCP too, as long as the agent can run shell commands.
|
|
176
|
+
This installs a skill that tells the agent to query flownav before reading, renaming or deleting nodes, and registers an MCP server (`flownav serve`, stdio) exposing `flownav_scan`, `explain`, `neighbors`, `path`, `find`, `affected`, `callers`, `calls`, `uses`, `check`, `read` and `report`. The server reads `.flownav/graph.json` from the working directory (or `--graph <file>`) and reloads it when the file changes. The skill works without MCP too, as long as the agent can run shell commands.
|
|
177
177
|
|
|
178
178
|
A typical rename: the agent runs `scan` to find the workflow id, then `affected "wf-orders:Normalize"`, edits the listed expressions together with the rename using whatever tool it has for editing n8n, syncs again, and runs `check` to confirm nothing dangles. flownav does not write to n8n; it tells the agent what is safe.
|
|
179
179
|
|
|
@@ -192,6 +192,38 @@ error: "order" matches 5 nodes. Do not guess: ask the user which one they mean.
|
|
|
192
192
|
|
|
193
193
|
If a partial name matches exactly one node, it resolves but the reply starts with a `NOTE:` asking the agent to confirm with the user before changing anything. To search a family of workflows, use `flownav find --match "<part of workflow name>" --name "<part of node name>"` (`--name` searches node names only; `--text` also searches parameters and code). The installed skill tells the agent to stop and ask in these cases. How reliably an agent follows that depends on the agent.
|
|
194
194
|
|
|
195
|
+
## Reading code in small pieces
|
|
196
|
+
|
|
197
|
+
flownav normally returns structure (cards, paths, references), not code. When an agent (or you) needs to read a large Code node, `read` returns it in small, capped slices instead of the whole node:
|
|
198
|
+
|
|
199
|
+
```text
|
|
200
|
+
$ flownav read "Orphan Code" # default: an outline
|
|
201
|
+
wf-orders:Orphan Code · jsCode · 3 line(s), 127 chars
|
|
202
|
+
longest line: 46 chars
|
|
203
|
+
functions: none found
|
|
204
|
+
node references:
|
|
205
|
+
Normalize line 1 {email}
|
|
206
|
+
Deleted Step line 2 {x}
|
|
207
|
+
next: grep for a name, or read a range with lines
|
|
208
|
+
|
|
209
|
+
$ flownav read "Orphan Code" --grep normalize
|
|
210
|
+
wf-orders:Orphan Code · jsCode · 3 line(s), 127 chars
|
|
211
|
+
> 1: const row = $('Normalize').first().json.email;
|
|
212
|
+
2: const gone = $('Deleted Step').first().json.x;
|
|
213
|
+
3: return [{ json: { row, gone } }];
|
|
214
|
+
1 of 1 matching line(s) shown
|
|
215
|
+
|
|
216
|
+
$ flownav read "Orphan Code" --lines 2-3
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- **Outline** (the default): size, the longest line, function names, and which nodes the code references, all with line numbers, so you can pick a slice without reading blindly.
|
|
220
|
+
- **`--grep <pattern>`**: matching lines with context (`--context`, default 2, up to 10), overlapping windows merged, up to `--max` matches (default 20). Literal and case-insensitive by default; `--regex` and `--case` change that.
|
|
221
|
+
- **`--lines 120-180`** (also `120` or `120-`): numbered lines, with a hint for the next slice. An open-ended range returns 60 lines.
|
|
222
|
+
- **Long lines:** a line over 240 characters is shown windowed, and `--cols 241-480` reads inside it, which is what you need for minified or generated code.
|
|
223
|
+
- **`--param`** reads another text parameter (for example an HTML field); without it, `read` uses `jsCode`, `pythonCode`, `functionCode` or `code`.
|
|
224
|
+
|
|
225
|
+
Every reply is capped at about 6,000 characters whatever you ask for, and secrets in the code are redacted (key-like assignments such as `apiKey = "..."`, and token shapes such as `Bearer ...`; this is best-effort, not a guarantee). `read` takes the code from your exported files, or does one read-only `GET` of that workflow from your instance (it needs the API key in the environment variable named by your profile, `N8N_API_KEY` by default). `--from <folder>` reads from a folder of exports instead. If the workflow changed since the graph was built, the reply starts with a warning and shows the current source. The MCP tool `flownav_read` does the same and cannot be pointed at other folders.
|
|
226
|
+
|
|
195
227
|
## Seeing the graph
|
|
196
228
|
|
|
197
229
|
```bash
|
|
@@ -252,6 +284,9 @@ flownav path <A> <B> [--max N --kinds a,b]
|
|
|
252
284
|
flownav find [--type --cred --text --name --resource --workflow --match --disabled --trigger]
|
|
253
285
|
flownav affected <node> --rename|--delete|--schema|--behavior
|
|
254
286
|
flownav callers <workflow> | calls <workflow> | uses --cred <c>
|
|
287
|
+
flownav read <node> [--outline | --grep p [--regex --case --context n --max n] | --lines a-b]
|
|
288
|
+
[--param p --cols a-b --from path] read part of a Code node, capped and redacted
|
|
289
|
+
flownav stats [--from path] measure raw JSON tokens against flownav replies on your workflows
|
|
255
290
|
flownav check [--only a,b] [--fail-on error|warn|info|none]
|
|
256
291
|
checks: fragile-reads, dead-refs, cycles, unreachable, dead-ends, no-error-handler, webhook-no-respond
|
|
257
292
|
flownav view [workflow] [--match text --collapse --focus <node> --depth N --out file.html --open]
|
|
@@ -295,6 +330,7 @@ For a manual "before I touch this" step, run `flownav affected "Node" --rename`
|
|
|
295
330
|
- `pinData` (sample execution data) is dropped on load and never stored.
|
|
296
331
|
- String parameters are redacted before being kept for search: values under secret-looking keys (`apiKey`, `token`, `password`, `Authorization`, ...) and known token shapes (Bearer, `sk-...`, GitHub, Slack, AWS, JWT).
|
|
297
332
|
- Credentials are recorded by id and type only.
|
|
333
|
+
- `flownav read` is the one command that returns code. It redacts key-like assignments and token shapes (best-effort), caps every reply, and for live graphs makes a single read-only `GET` of the one workflow it needs.
|
|
298
334
|
- Execution data is not fetched; `--executions` records only the last status per workflow.
|
|
299
335
|
- `.flownav/` holds your graph and is git-ignored by this repo. Don't commit it or share the HTML views if your workflows are sensitive.
|
|
300
336
|
|
|
@@ -303,7 +339,7 @@ For a manual "before I touch this" step, run `flownav affected "Node" --rename`
|
|
|
303
339
|
This is a back-of-the-envelope model, not a measurement of real agent sessions. Tokens are estimated as characters ÷ 4.
|
|
304
340
|
|
|
305
341
|
- **n**: nodes the agent would otherwise read; **τ**: tokens per node in raw n8n JSON (assumed 300, it varies a lot: Code and HTML nodes are far heavier than a Set node); **W = n·τ**.
|
|
306
|
-
- **S ≈ 1,
|
|
342
|
+
- **S ≈ 1,600 tokens**: flownav's fixed overhead per session (the 12 MCP tool definitions, about 1,000 tokens, plus the skill, about 550).
|
|
307
343
|
- **r ≈ 100 tokens**: a typical reply. `explain` and `affected` replies are tens of tokens; a `scan` of a dozen workflows is a few hundred.
|
|
308
344
|
- With prompt caching, text read once costs about 1.25× the first time and about 0.1× on each later turn. For Q questions:
|
|
309
345
|
- raw: `C_raw ≈ W · (1.25 + 0.1·(Q − 1))`
|
|
@@ -316,25 +352,25 @@ Raw reading grows with the workflow; a flownav reply depends only on a node's lo
|
|
|
316
352
|
|
|
317
353
|
| workflow size | raw tokens | 1 question | 10 questions |
|
|
318
354
|
|---|---|---|---|
|
|
319
|
-
| 5 nodes | 1,500 |
|
|
320
|
-
| 10 nodes | 3,000 |
|
|
321
|
-
| 30 nodes | 9,000 |
|
|
322
|
-
| 132 nodes | 40,000 |
|
|
323
|
-
| whole instance (443 nodes) | 133,000 | 99% |
|
|
355
|
+
| 5 nodes | 1,500 | −12% (costs more) | −38% (costs more) |
|
|
356
|
+
| 10 nodes | 3,000 | 44% | 31% |
|
|
357
|
+
| 30 nodes | 9,000 | 81% | 77% |
|
|
358
|
+
| 132 nodes | 40,000 | 96% | 95% |
|
|
359
|
+
| whole instance (443 nodes) | 133,000 | 99% | 98% |
|
|
324
360
|
|
|
325
361
|
**Saving when the agent searches and reads only a fraction of a 132-node workflow:**
|
|
326
362
|
|
|
327
363
|
| fraction read | 1 question | 10 questions |
|
|
328
364
|
|---|---|---|
|
|
329
|
-
| 30% |
|
|
330
|
-
| 20% |
|
|
331
|
-
| 10% |
|
|
365
|
+
| 30% | 86% | 83% |
|
|
366
|
+
| 20% | 79% | 74% |
|
|
367
|
+
| 10% | 58% | 48% |
|
|
332
368
|
|
|
333
369
|
What to take from it:
|
|
334
370
|
|
|
335
371
|
- Below roughly 6 nodes, reading the JSON is cheaper than loading the tools. Around 10 to 30 nodes flownav saves about half to four fifths; on large workflows the saving approaches 100%.
|
|
336
372
|
- Most agents search instead of reading everything, so for large workflows a realistic range is about 55 to 90%; the 96% rows are the best case.
|
|
337
|
-
- τ is the main unknown
|
|
373
|
+
- τ is the main unknown, and `flownav stats` measures it on your own workflows (the bundled examples come out at about 130 tokens per node, well below the 300 assumed here, because they are small and hand-written; real Code and HTML nodes are far heavier). You can also run the same task with and without flownav and compare the usage Claude reports, several times, or measure the size of your own exports, or run the same task with and without flownav and compare the usage Claude reports, several times, since agent runs vary.
|
|
338
374
|
- Tokens are not the only benefit: `affected` finds `$('Name')` references in expressions and Code nodes that a skimming agent misses.
|
|
339
375
|
|
|
340
376
|
## Limitations
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,9 @@ import { G } from './graph.js';
|
|
|
10
10
|
import { installClaude } from './install.js';
|
|
11
11
|
import { serve } from './mcp.js';
|
|
12
12
|
import { affected, callers, calls, explain, find, neighbors, paths, uses } from './queries.js';
|
|
13
|
+
import { readNode } from './read.js';
|
|
13
14
|
import { report, scan } from './report.js';
|
|
15
|
+
import { stats } from './stats.js';
|
|
14
16
|
import { buildFromFiles, N8nApi, syncApi } from './source.js';
|
|
15
17
|
import { DEFAULT_DIR, graphPath, loadGraph, loadProfiles, saveGraph, saveProfiles, writeText } from './store.js';
|
|
16
18
|
import { FLOW_KINDS } from './types.js';
|
|
@@ -189,6 +191,33 @@ program
|
|
|
189
191
|
if (failOn !== 'none' && f.some((x) => rank[x.severity] <= rank[failOn]))
|
|
190
192
|
process.exitCode = 1;
|
|
191
193
|
})());
|
|
194
|
+
program
|
|
195
|
+
.command('read <node>')
|
|
196
|
+
.description("Read part of a node's code in small pieces: outline (default), --grep, or --lines")
|
|
197
|
+
.option('--param <name>', 'parameter to read (default: the code parameter)')
|
|
198
|
+
.option('--lines <range>', '"120-180", "120" or "120-"')
|
|
199
|
+
.option('--grep <pattern>', 'lines matching a pattern, with context')
|
|
200
|
+
.option('--regex', 'treat the grep pattern as a regular expression')
|
|
201
|
+
.option('--case', 'case-sensitive grep')
|
|
202
|
+
.option('--context <n>', 'lines around each grep match', '2')
|
|
203
|
+
.option('--max <n>', 'grep matches to show', '20')
|
|
204
|
+
.option('--outline', 'size, functions and node references with line numbers')
|
|
205
|
+
.option('--cols <range>', 'character window per line, e.g. 241-480')
|
|
206
|
+
.option('--from <path>', 'read the source from this folder of exported workflows')
|
|
207
|
+
.action((n, o) => run(async () => {
|
|
208
|
+
emit(await readNode(load(), {
|
|
209
|
+
node: n, param: o.param, lines: o.lines, grep: o.grep, regex: o.regex, caseSensitive: o.case,
|
|
210
|
+
context: Number(o.context), max: Number(o.max), outline: o.outline, cols: o.cols, from: o.from,
|
|
211
|
+
dir: dirname(opts().graph ?? graphPath()),
|
|
212
|
+
}));
|
|
213
|
+
})());
|
|
214
|
+
program
|
|
215
|
+
.command('stats')
|
|
216
|
+
.description('Measure raw workflow JSON size against flownav replies on your own workflows')
|
|
217
|
+
.option('--from <path>', 'read the workflows from this folder of exports')
|
|
218
|
+
.action((o) => run(async () => {
|
|
219
|
+
emit(await stats(load(), { from: o.from, dir: dirname(opts().graph ?? graphPath()) }));
|
|
220
|
+
})());
|
|
192
221
|
program
|
|
193
222
|
.command('diagram [workflow]')
|
|
194
223
|
.description('Mermaid flowchart of a workflow, everything, or --focus <node>')
|
package/dist/install.js
CHANGED
|
@@ -19,6 +19,7 @@ Then query the graph; do not open workflow JSON unless the graph cannot answer.
|
|
|
19
19
|
- \`flownav affected "<node>" --rename|--delete|--schema|--behavior\` **run before renaming or deleting a node**: references like \`$('Old Name')\` are not reliably rewritten (reportedly not inside Code nodes, and not for renames made through the API or by editing JSON), so list them first
|
|
20
20
|
- \`flownav callers <workflow>\` / \`calls <workflow>\` cross-workflow links
|
|
21
21
|
- \`flownav check\` fragile reads, dead refs, unreachable nodes, cycles, missing error handling
|
|
22
|
+
- \`flownav read "<node>"\` read part of a Code node: start with the default outline (size, functions, node references with line numbers), then \`--grep "text"\` or \`--lines 120-180\`. Never read a whole large node at once; every reply is capped and secrets are redacted
|
|
22
23
|
|
|
23
24
|
## When the request is not specific
|
|
24
25
|
|
package/dist/mcp.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { statSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
2
3
|
import { createInterface } from 'node:readline';
|
|
3
4
|
import { ALL_CHECKS, formatFindings, runChecks } from './checks.js';
|
|
4
5
|
import { G } from './graph.js';
|
|
5
6
|
import { affected, callers, calls, explain, find, neighbors, paths, uses } from './queries.js';
|
|
7
|
+
import { readNode } from './read.js';
|
|
6
8
|
import { report, scan } from './report.js';
|
|
7
9
|
import { graphPath, loadGraph } from './store.js';
|
|
8
10
|
import { VERSION } from './version.js';
|
|
@@ -18,6 +20,12 @@ const TOOLS = [
|
|
|
18
20
|
{ name: 'flownav_calls', description: 'What this workflow calls, plus unresolved dynamic calls.', schema: { workflow: str }, run: (g, a) => calls(g, a.workflow) },
|
|
19
21
|
{ name: 'flownav_uses', description: 'Nodes that use a credential (id, name or type).', schema: { credential: str }, run: (g, a) => uses(g, a.credential) },
|
|
20
22
|
{ name: 'flownav_check', description: `Health checks: ${ALL_CHECKS.join(', ')}. Pass only="a,b" to run some.`, schema: { only: str }, run: (g, a) => formatFindings(g, runChecks(g, a.only ? String(a.only).split(',').map((x) => x.trim()) : undefined)) },
|
|
23
|
+
{
|
|
24
|
+
name: 'flownav_read',
|
|
25
|
+
description: 'Read PART of one node\'s code (or other long text) instead of the whole node. Start with the default outline (size, functions, which nodes the code references, with line numbers), then grep for a name or read a line range. Replies are capped at about 6,000 characters and secrets are redacted. Use cols to read inside very long (minified) lines. If the reply says the workflow changed since the graph was built, ask the user to sync. If the name is ambiguous or only a partial match, ask the user which node before acting.',
|
|
26
|
+
schema: { node: str, param: str, lines: str, grep: str, regex: { type: 'boolean' }, caseSensitive: { type: 'boolean' }, context: { type: 'number' }, max: { type: 'number' }, outline: { type: 'boolean' }, cols: str },
|
|
27
|
+
run: (g, a, ctx) => readNode(g, { node: a.node, param: a.param, lines: a.lines, grep: a.grep, regex: a.regex, caseSensitive: a.caseSensitive, context: a.context, max: a.max, outline: a.outline, cols: a.cols, dir: ctx.dir }),
|
|
28
|
+
},
|
|
21
29
|
{ name: 'flownav_report', description: 'Full markdown workflow report.', schema: {}, run: (g) => report(g) },
|
|
22
30
|
];
|
|
23
31
|
export function serve(graphFile) {
|
|
@@ -63,13 +71,9 @@ export function serve(graphFile) {
|
|
|
63
71
|
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
64
72
|
if (!tool)
|
|
65
73
|
return void fail(-32602, `Unknown tool ${params?.name}`);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
reply({ content: [{ type: 'text', text: typeof r === 'string' ? r : r.text }] });
|
|
69
|
-
}
|
|
70
|
-
catch (err) {
|
|
71
|
-
reply({ isError: true, content: [{ type: 'text', text: err.message }] });
|
|
72
|
-
}
|
|
74
|
+
Promise.resolve()
|
|
75
|
+
.then(() => tool.run(getG(), params.arguments ?? {}, { dir: dirname(file) }))
|
|
76
|
+
.then((r) => reply({ content: [{ type: 'text', text: typeof r === 'string' ? r : r.text }] }), (err) => reply({ isError: true, content: [{ type: 'text', text: err.message }] }));
|
|
73
77
|
}
|
|
74
78
|
else if (id !== undefined) {
|
|
75
79
|
fail(-32601, `Method not found: ${method}`);
|
package/dist/read.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { buildWorkflow } from './build.js';
|
|
3
|
+
import { extractRefs } from './expressions.js';
|
|
4
|
+
import { extractWorkflows, loadFiles, N8nApi } from './source.js';
|
|
5
|
+
import { loadProfiles } from './store.js';
|
|
6
|
+
import { redactString } from './util.js';
|
|
7
|
+
/** Hard cap on what one call returns, whatever the request asks for. */
|
|
8
|
+
export const MAX_CHARS = 6000;
|
|
9
|
+
const DEFAULT_WIDTH = 240;
|
|
10
|
+
const MAX_LINE_SCAN = 5000;
|
|
11
|
+
const CODE_PARAMS = ['jsCode', 'pythonCode', 'functionCode', 'code'];
|
|
12
|
+
/* ---------------- source ---------------- */
|
|
13
|
+
export async function loadRaw(g, wfId, o) {
|
|
14
|
+
const info = g.g.workflows[wfId];
|
|
15
|
+
const pick = (wfs) => wfs.find((w) => w.id != null && String(w.id) === wfId) ??
|
|
16
|
+
wfs.find((w) => w.name === info.name) ??
|
|
17
|
+
(wfs.length === 1 ? wfs[0] : undefined);
|
|
18
|
+
if (o.from) {
|
|
19
|
+
const hit = pick(loadFiles(o.from).items.map((i) => i.raw));
|
|
20
|
+
if (!hit)
|
|
21
|
+
throw new Error(`Workflow "${info.name}" (${wfId}) was not found under ${o.from}.`);
|
|
22
|
+
return hit;
|
|
23
|
+
}
|
|
24
|
+
if (g.g.source.type === 'files') {
|
|
25
|
+
if (!info.file)
|
|
26
|
+
throw new Error(`The graph does not record a source file for ${wfId}. Rebuild it, or pass a folder of exports.`);
|
|
27
|
+
let wfs;
|
|
28
|
+
try {
|
|
29
|
+
wfs = extractWorkflows(JSON.parse(readFileSync(info.file, 'utf8')));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new Error(`Cannot read ${info.file}. Pass the folder of exports to read from.`);
|
|
33
|
+
}
|
|
34
|
+
const hit = pick(wfs);
|
|
35
|
+
if (!hit)
|
|
36
|
+
throw new Error(`Workflow "${info.name}" is not in ${info.file} any more.`);
|
|
37
|
+
delete hit.pinData;
|
|
38
|
+
return hit;
|
|
39
|
+
}
|
|
40
|
+
const profile = g.g.source.profile ? loadProfiles(o.dir)[g.g.source.profile] : undefined;
|
|
41
|
+
const keyEnv = profile?.keyEnv ?? 'N8N_API_KEY';
|
|
42
|
+
const key = process.env[keyEnv];
|
|
43
|
+
if (!key) {
|
|
44
|
+
throw new Error(`Reading code from the live instance needs the API key in the ${keyEnv} environment variable of this process (read-only GET). Set it, or read from exported files instead.`);
|
|
45
|
+
}
|
|
46
|
+
const wf = await new N8nApi(g.g.source.ref, key).get(`/workflows/${encodeURIComponent(wfId)}`);
|
|
47
|
+
delete wf.pinData;
|
|
48
|
+
return wf;
|
|
49
|
+
}
|
|
50
|
+
/* ---------------- parameter + redaction ---------------- */
|
|
51
|
+
function getPath(obj, path) {
|
|
52
|
+
// accepts the paths this module prints, e.g. "assignments.assignments[0].value"
|
|
53
|
+
return path.replace(/\[(\d+)\]/g, '.$1').split('.').filter(Boolean).reduce((a, k) => (a == null ? undefined : a[k]), obj);
|
|
54
|
+
}
|
|
55
|
+
function textParams(v, prefix = '', out = [], depth = 0) {
|
|
56
|
+
if (depth > 8 || v == null)
|
|
57
|
+
return out;
|
|
58
|
+
if (typeof v === 'string') {
|
|
59
|
+
if (v.length)
|
|
60
|
+
out.push({ path: prefix, len: v.length });
|
|
61
|
+
}
|
|
62
|
+
else if (Array.isArray(v)) {
|
|
63
|
+
v.forEach((x, i) => textParams(x, `${prefix}[${i}]`, out, depth + 1));
|
|
64
|
+
}
|
|
65
|
+
else if (typeof v === 'object') {
|
|
66
|
+
for (const [k, x] of Object.entries(v))
|
|
67
|
+
textParams(x, prefix ? `${prefix}.${k}` : k, out, depth + 1);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
function listText(node) {
|
|
72
|
+
const rows = textParams(node.parameters).sort((a, b) => b.len - a.len).slice(0, 8);
|
|
73
|
+
return rows.length ? `Text parameters: ${rows.map((r) => `${r.path} (${r.len.toLocaleString('en-US')} chars)`).join(', ')}.` : 'It has no text parameters.';
|
|
74
|
+
}
|
|
75
|
+
function pickParam(node, wanted) {
|
|
76
|
+
const p = (node.parameters ?? {});
|
|
77
|
+
if (wanted) {
|
|
78
|
+
const v = getPath(p, wanted);
|
|
79
|
+
if (typeof v !== 'string')
|
|
80
|
+
throw new Error(`Parameter "${wanted}" of ${node.name} is not text. ${listText(node)}`);
|
|
81
|
+
return { name: wanted, text: v };
|
|
82
|
+
}
|
|
83
|
+
for (const k of CODE_PARAMS)
|
|
84
|
+
if (typeof p[k] === 'string')
|
|
85
|
+
return { name: k, text: p[k] };
|
|
86
|
+
throw new Error(`${node.name} has no code parameter; pass param. ${listText(node)}`);
|
|
87
|
+
}
|
|
88
|
+
const KEYED = /((?:api[-_]?key|token|secret|passw(?:or)?d|authorization|private[-_]?key|client[-_]?secret)["']?\s*[:=]\s*)(["'`])([^"'`\n]{6,})\2/gi;
|
|
89
|
+
/** Best-effort redaction of a line of code: key-like assignments and known token shapes. */
|
|
90
|
+
export function redactCode(line) {
|
|
91
|
+
return redactString(line.replace(KEYED, (_m, pre, q) => `${pre}${q}[REDACTED]${q}`));
|
|
92
|
+
}
|
|
93
|
+
/* ---------------- formatting ---------------- */
|
|
94
|
+
function parseRange(s, what) {
|
|
95
|
+
const m = /^\s*(\d*)\s*(-?)\s*(\d*)\s*$/.exec(s ?? '');
|
|
96
|
+
if (!s || !m || (!m[1] && !m[3]))
|
|
97
|
+
throw new Error(`${what} must look like "120-180", "120" or "120-".`);
|
|
98
|
+
const a = m[1] ? Number(m[1]) : 1;
|
|
99
|
+
const b = m[2] ? (m[3] ? Number(m[3]) : undefined) : a;
|
|
100
|
+
if (a < 1 || (b !== undefined && b < a))
|
|
101
|
+
throw new Error(`${what} "${s}" is not a valid range.`);
|
|
102
|
+
return [a, b];
|
|
103
|
+
}
|
|
104
|
+
/** One line for display: windowed and marked when it is longer than the window. */
|
|
105
|
+
function show(line, cols, focus) {
|
|
106
|
+
let start = 0;
|
|
107
|
+
let end = line.length;
|
|
108
|
+
if (cols) {
|
|
109
|
+
start = cols[0] - 1;
|
|
110
|
+
end = cols[1] ?? start + DEFAULT_WIDTH;
|
|
111
|
+
}
|
|
112
|
+
else if (line.length > DEFAULT_WIDTH) {
|
|
113
|
+
start = focus != null && focus > DEFAULT_WIDTH - 40 ? Math.max(0, focus - 80) : 0;
|
|
114
|
+
end = start + DEFAULT_WIDTH;
|
|
115
|
+
}
|
|
116
|
+
if (start >= line.length && line.length > 0)
|
|
117
|
+
return `[line has ${line.length} chars; the window starts at column ${start + 1}]`;
|
|
118
|
+
const body = line.slice(start, end);
|
|
119
|
+
return `${start > 0 ? '…' : ''}${body}${end < line.length ? `… [+${(line.length - end).toLocaleString('en-US')} chars, ${line.length.toLocaleString('en-US')} total]` : ''}`;
|
|
120
|
+
}
|
|
121
|
+
const lineIndexOf = (text, at) => text.slice(0, at).split('\n').length;
|
|
122
|
+
function outlineOf(text, lines) {
|
|
123
|
+
const out = [];
|
|
124
|
+
const longest = lines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
125
|
+
out.push(`longest line: ${longest.toLocaleString('en-US')} chars${longest > 1000 ? ' (minified or generated: use cols to read inside long lines)' : ''}`);
|
|
126
|
+
const fns = [];
|
|
127
|
+
lines.forEach((l, i) => {
|
|
128
|
+
const m = /^\s*(?:export\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/.exec(l) ??
|
|
129
|
+
/^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/.exec(l) ??
|
|
130
|
+
/^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?function/.exec(l) ??
|
|
131
|
+
/^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/.exec(l);
|
|
132
|
+
if (m)
|
|
133
|
+
fns.push(`L${i + 1} ${m[1]}`);
|
|
134
|
+
});
|
|
135
|
+
out.push(fns.length ? `functions (${fns.length}): ${fns.slice(0, 30).join(', ')}${fns.length > 30 ? ', …' : ''}` : 'functions: none found');
|
|
136
|
+
const ex = extractRefs(text);
|
|
137
|
+
if (ex.refs.length || ex.inferred.length) {
|
|
138
|
+
out.push('node references:');
|
|
139
|
+
for (const r of ex.refs) {
|
|
140
|
+
const ls = [...new Set(r.at.map((a) => lineIndexOf(text, a)))];
|
|
141
|
+
out.push(` ${r.name} line${ls.length > 1 ? 's' : ''} ${ls.slice(0, 12).join(', ')}${ls.length > 12 ? ', …' : ''}${r.fields.length ? ` {${r.fields.join(', ')}}` : ''}`);
|
|
142
|
+
}
|
|
143
|
+
if (ex.inferred.length)
|
|
144
|
+
out.push(` via a literal name list (inferred): ${ex.inferred.join(', ')}`);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
out.push('node references: none');
|
|
148
|
+
}
|
|
149
|
+
if (ex.dynamic)
|
|
150
|
+
out.push(`computed-name references: ${ex.dynamic} (cannot be resolved statically)`);
|
|
151
|
+
if (ex.env.length)
|
|
152
|
+
out.push(`env vars: ${ex.env.join(', ')}`);
|
|
153
|
+
if (ex.vars.length)
|
|
154
|
+
out.push(`vars: ${ex.vars.join(', ')}`);
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
/* ---------------- main ---------------- */
|
|
158
|
+
export async function readNode(g, o) {
|
|
159
|
+
const modes = [o.lines != null, o.grep != null, o.outline === true].filter(Boolean).length;
|
|
160
|
+
if (modes > 1)
|
|
161
|
+
throw new Error('Give only one of: lines, grep, outline.');
|
|
162
|
+
const { node: n, fuzzy } = g.resolveNodeEx(o.node);
|
|
163
|
+
const wf = await loadRaw(g, n.wf, o);
|
|
164
|
+
const notes = [];
|
|
165
|
+
if (fuzzy)
|
|
166
|
+
notes.push(`NOTE: "${fuzzy}" was only a partial name and matched just ${g.label(n.id)}. Confirm with the user that this is the node they mean before changing anything.`);
|
|
167
|
+
if (buildWorkflow(wf, n.wf).info.hash !== g.g.workflows[n.wf].hash) {
|
|
168
|
+
notes.push('WARNING: this workflow changed since the graph was built, so line numbers and references may not match the graph. Run flownav sync (or build) to refresh it. Showing the current source.');
|
|
169
|
+
}
|
|
170
|
+
const raw = wf.nodes?.find((x) => x.name === n.name);
|
|
171
|
+
if (!raw)
|
|
172
|
+
throw new Error(`Node "${n.name}" is no longer in the current source of ${g.wfName(n.wf)}. The graph is stale: run flownav sync (or build).`);
|
|
173
|
+
const { name: param, text } = pickParam(raw, o.param);
|
|
174
|
+
const lines = text.split(/\r?\n/);
|
|
175
|
+
const total = lines.length;
|
|
176
|
+
const safe = lines.map(redactCode);
|
|
177
|
+
const cols = o.cols ? parseRange(o.cols, 'cols') : undefined;
|
|
178
|
+
const head = `${g.label(n.id)} · ${param} · ${total.toLocaleString('en-US')} line(s), ${text.length.toLocaleString('en-US')} chars`;
|
|
179
|
+
const out = [...notes, head];
|
|
180
|
+
let used = out.join('\n').length;
|
|
181
|
+
const data = { node: n.id, param, totalLines: total, chars: text.length };
|
|
182
|
+
const push = (s) => {
|
|
183
|
+
if (used + s.length + 1 > MAX_CHARS)
|
|
184
|
+
return false;
|
|
185
|
+
out.push(s);
|
|
186
|
+
used += s.length + 1;
|
|
187
|
+
return true;
|
|
188
|
+
};
|
|
189
|
+
if (!text.length) {
|
|
190
|
+
out.push('(empty)');
|
|
191
|
+
return { text: out.join('\n'), data };
|
|
192
|
+
}
|
|
193
|
+
if (o.lines != null) {
|
|
194
|
+
const [a, b] = parseRange(o.lines, 'lines');
|
|
195
|
+
if (a > total)
|
|
196
|
+
throw new Error(`lines "${o.lines}" starts past the end: ${param} has ${total} line(s).`);
|
|
197
|
+
const end = Math.min(b ?? a + 59, total, a + 199);
|
|
198
|
+
const w = String(end).length;
|
|
199
|
+
let shown = a - 1;
|
|
200
|
+
for (let i = a; i <= end; i++) {
|
|
201
|
+
if (!push(` ${String(i).padStart(w)}: ${show(safe[i - 1], cols)}`))
|
|
202
|
+
break;
|
|
203
|
+
shown = i;
|
|
204
|
+
}
|
|
205
|
+
const next = shown < total ? `; next: lines ${shown + 1}-${Math.min(total, shown + 60)}` : ' (end)';
|
|
206
|
+
out.push(`showing ${a}-${shown} of ${total}${shown < end ? ` (output capped at ${MAX_CHARS} chars)` : ''}${next}`);
|
|
207
|
+
Object.assign(data, { mode: 'lines', from: a, to: shown });
|
|
208
|
+
return { text: out.join('\n'), data };
|
|
209
|
+
}
|
|
210
|
+
if (o.grep != null) {
|
|
211
|
+
const pattern = o.grep;
|
|
212
|
+
if (!pattern)
|
|
213
|
+
throw new Error('grep needs a pattern.');
|
|
214
|
+
if (o.regex && pattern.length > 200)
|
|
215
|
+
throw new Error('A regex pattern may be at most 200 characters.');
|
|
216
|
+
let re;
|
|
217
|
+
try {
|
|
218
|
+
re = new RegExp(o.regex ? pattern : pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), o.caseSensitive ? '' : 'i');
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
throw new Error(`Invalid regex: ${e.message}`);
|
|
222
|
+
}
|
|
223
|
+
const ctx = Math.min(Math.max(o.context ?? 2, 0), 10);
|
|
224
|
+
const max = Math.min(Math.max(o.max ?? 20, 1), 100);
|
|
225
|
+
const hits = [];
|
|
226
|
+
safe.forEach((l, i) => {
|
|
227
|
+
const m = re.exec(l.slice(0, MAX_LINE_SCAN));
|
|
228
|
+
if (m)
|
|
229
|
+
hits.push({ i, col: m.index });
|
|
230
|
+
});
|
|
231
|
+
if (!hits.length) {
|
|
232
|
+
out.push(`no match for ${o.regex ? 'regex ' : ''}"${pattern}"${o.caseSensitive ? '' : ' (case-insensitive)'}. Try a shorter pattern, regex=true, or outline.`);
|
|
233
|
+
Object.assign(data, { mode: 'grep', matches: 0 });
|
|
234
|
+
return { text: out.join('\n'), data };
|
|
235
|
+
}
|
|
236
|
+
const use = hits.slice(0, max);
|
|
237
|
+
const focus = new Map(use.map((h) => [h.i, h.col]));
|
|
238
|
+
const groups = [];
|
|
239
|
+
for (const h of use) {
|
|
240
|
+
const from = Math.max(0, h.i - ctx);
|
|
241
|
+
const to = Math.min(total - 1, h.i + ctx);
|
|
242
|
+
const last = groups[groups.length - 1];
|
|
243
|
+
if (last && from <= last[1] + 1)
|
|
244
|
+
last[1] = Math.max(last[1], to);
|
|
245
|
+
else
|
|
246
|
+
groups.push([from, to]);
|
|
247
|
+
}
|
|
248
|
+
const w = String(total).length;
|
|
249
|
+
let shownMatches = 0;
|
|
250
|
+
let capped = false;
|
|
251
|
+
outer: for (let gi = 0; gi < groups.length; gi++) {
|
|
252
|
+
if (gi > 0 && !push(' --')) {
|
|
253
|
+
capped = true;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
for (let i = groups[gi][0]; i <= groups[gi][1]; i++) {
|
|
257
|
+
const isHit = focus.has(i);
|
|
258
|
+
if (!push(`${isHit ? '>' : ' '} ${String(i + 1).padStart(w)}: ${show(safe[i], cols, focus.get(i))}`)) {
|
|
259
|
+
capped = true;
|
|
260
|
+
break outer;
|
|
261
|
+
}
|
|
262
|
+
if (isHit)
|
|
263
|
+
shownMatches++;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
out.push(`${shownMatches} of ${hits.length} matching line(s) shown${capped ? ` (output capped at ${MAX_CHARS} chars)` : ''}${hits.length > shownMatches ? '; narrow the pattern, raise max, or read a range with lines' : ''}`);
|
|
267
|
+
Object.assign(data, { mode: 'grep', matches: hits.length, shown: shownMatches });
|
|
268
|
+
return { text: out.join('\n'), data };
|
|
269
|
+
}
|
|
270
|
+
// default and explicit outline
|
|
271
|
+
for (const l of outlineOf(text, lines))
|
|
272
|
+
if (!push(l))
|
|
273
|
+
break;
|
|
274
|
+
out.push('next: grep for a name, or read a range with lines');
|
|
275
|
+
Object.assign(data, { mode: 'outline' });
|
|
276
|
+
return { text: out.join('\n'), data };
|
|
277
|
+
}
|
package/dist/stats.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { formatFindings, runChecks } from './checks.js';
|
|
2
|
+
import { affected, explain } from './queries.js';
|
|
3
|
+
import { loadRaw } from './read.js';
|
|
4
|
+
import { scan } from './report.js';
|
|
5
|
+
import { trunc } from './util.js';
|
|
6
|
+
const tok = (s) => Math.round(s.length / 4);
|
|
7
|
+
const fmt = (n) => n.toLocaleString('en-US');
|
|
8
|
+
function median(xs) {
|
|
9
|
+
if (!xs.length)
|
|
10
|
+
return 0;
|
|
11
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
12
|
+
const m = Math.floor(s.length / 2);
|
|
13
|
+
return s.length % 2 ? s[m] : Math.round((s[m - 1] + s[m]) / 2);
|
|
14
|
+
}
|
|
15
|
+
const pct = (small, big) => (big > 0 ? `${Math.max(0, Math.round((1 - small / big) * 100))}% fewer` : 'n/a');
|
|
16
|
+
/**
|
|
17
|
+
* Measure, on your own workflows, how much an agent would read without flownav (the whole
|
|
18
|
+
* workflow JSON) against what flownav replies with. Tokens are estimated as characters / 4.
|
|
19
|
+
*/
|
|
20
|
+
export async function stats(g, o) {
|
|
21
|
+
const wfs = Object.values(g.g.workflows);
|
|
22
|
+
const rows = [];
|
|
23
|
+
for (const w of wfs) {
|
|
24
|
+
const raw = await loadRaw(g, w.id, o);
|
|
25
|
+
delete raw.pinData;
|
|
26
|
+
const rawTokens = tok(JSON.stringify(raw, null, 2));
|
|
27
|
+
rows.push({ id: w.id, name: w.name, nodes: w.nodeCount, rawTokens, perNode: w.nodeCount ? Math.round(rawTokens / w.nodeCount) : 0 });
|
|
28
|
+
}
|
|
29
|
+
rows.sort((a, b) => b.rawTokens - a.rawTokens);
|
|
30
|
+
const explainToks = g.g.nodes.map((n) => tok(explain(g, n.id).text));
|
|
31
|
+
const withReaders = g.g.nodes.filter((n) => g.outE(n.id, ['reads']).length);
|
|
32
|
+
const affectedToks = withReaders.map((n) => tok(affected(g, n.id, 'rename').text));
|
|
33
|
+
const scanTok = tok(scan(g).text);
|
|
34
|
+
const checkTok = tok(formatFindings(g, runChecks(g)).text);
|
|
35
|
+
const totalRaw = rows.reduce((a, r) => a + r.rawTokens, 0);
|
|
36
|
+
const totalNodes = rows.reduce((a, r) => a + r.nodes, 0);
|
|
37
|
+
const medRaw = median(rows.map((r) => r.rawTokens));
|
|
38
|
+
const medExplain = median(explainToks);
|
|
39
|
+
const medAffected = median(affectedToks);
|
|
40
|
+
const tau = totalNodes ? Math.round(totalRaw / totalNodes) : 0;
|
|
41
|
+
const w = Math.min(36, Math.max(...rows.map((r) => r.name.length), 8));
|
|
42
|
+
const L = [];
|
|
43
|
+
L.push(`${'workflow'.padEnd(w)} ${'nodes'.padStart(5)} ${'raw tokens'.padStart(10)} ${'per node'.padStart(8)}`);
|
|
44
|
+
for (const r of rows)
|
|
45
|
+
L.push(`${trunc(r.name, w).padEnd(w)} ${String(r.nodes).padStart(5)} ${fmt(r.rawTokens).padStart(10)} ${String(r.perNode).padStart(8)}`);
|
|
46
|
+
L.push(`${'total'.padEnd(w)} ${String(totalNodes).padStart(5)} ${fmt(totalRaw).padStart(10)} ${String(tau).padStart(8)}`);
|
|
47
|
+
L.push('');
|
|
48
|
+
L.push('Cost of one question about one workflow (median):');
|
|
49
|
+
L.push(` read the whole workflow JSON ${fmt(medRaw).padStart(8)} tokens`);
|
|
50
|
+
L.push(` flownav explain ${fmt(medExplain).padStart(8)} tokens ${pct(medExplain, medRaw)}`);
|
|
51
|
+
L.push(` flownav affected --rename ${fmt(medAffected).padStart(8)} tokens ${pct(medAffected, medRaw)}${withReaders.length ? '' : ' (no references found)'}`);
|
|
52
|
+
L.push('');
|
|
53
|
+
L.push('Whole instance:');
|
|
54
|
+
L.push(` read every workflow ${fmt(totalRaw).padStart(8)} tokens`);
|
|
55
|
+
L.push(` flownav scan ${fmt(scanTok).padStart(8)} tokens ${pct(scanTok, totalRaw)}`);
|
|
56
|
+
L.push(` flownav check (all findings) ${fmt(checkTok).padStart(8)} tokens ${pct(checkTok, totalRaw)}`);
|
|
57
|
+
L.push('');
|
|
58
|
+
L.push(`Tokens are estimated as characters / 4 on each workflow's JSON re-printed with 2-space indentation (about ${tau} tokens per node here).`);
|
|
59
|
+
L.push('These compare against an agent that reads the whole workflow. An agent that searches first reads less, so treat the percentages as upper bounds.');
|
|
60
|
+
return {
|
|
61
|
+
text: L.join('\n'),
|
|
62
|
+
data: { workflows: rows, totalRawTokens: totalRaw, tokensPerNode: tau, median: { workflow: medRaw, explain: medExplain, affected: medAffected }, scan: scanTok, check: checkTok },
|
|
63
|
+
};
|
|
64
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flownav",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "See what references an n8n node before you rename it. A typed graph of your workflows with a CLI and an MCP server, so AI agents read a few lines instead of the whole workflow JSON.",
|
|
5
5
|
"author": "Omer Turkenits",
|
|
6
6
|
"license": "MIT",
|