flownav 0.1.3 → 0.2.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.
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
 
@@ -185,12 +185,47 @@ Tool replies are written for an agent to act on. If a name matches several nodes
185
185
  $ flownav explain order
186
186
  error: "order" matches 5 nodes. Do not guess: ask the user which one they mean. Then use the workflow:node form.
187
187
  1. Order intake › Order Webhook [webhook] use: wf-orders:Order Webhook
188
+ Webhook POST /orders (responseNode) · group "Intake" · starts the workflow
188
189
  2. Order intake › Is Large Order [if] use: wf-orders:Is Large Order
190
+ IF 1 condition(s) · group "Intake" · after: Normalize
189
191
  3. Order intake › Save Order [googleSheets] use: wf-orders:Save Order
192
+ googleSheets: append · group "Intake" · after: Is Large Order
190
193
  ...
191
194
  ```
192
195
 
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.
196
+ Each candidate carries one line of context (what the node does, its sticky-note group, and what feeds it or what it plugs into), so the agent can ask a specific question instead of "which one?". Workflow candidates show their trigger types and whether they are active. 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.
197
+
198
+ ## Reading code in small pieces
199
+
200
+ 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:
201
+
202
+ ```text
203
+ $ flownav read "Orphan Code" # default: an outline
204
+ wf-orders:Orphan Code · jsCode · 3 line(s), 127 chars
205
+ longest line: 46 chars
206
+ functions: none found
207
+ node references:
208
+ Normalize line 1 {email}
209
+ Deleted Step line 2 {x}
210
+ next: grep for a name, or read a range with lines
211
+
212
+ $ flownav read "Orphan Code" --grep normalize
213
+ wf-orders:Orphan Code · jsCode · 3 line(s), 127 chars
214
+ > 1: const row = $('Normalize').first().json.email;
215
+ 2: const gone = $('Deleted Step').first().json.x;
216
+ 3: return [{ json: { row, gone } }];
217
+ 1 of 1 matching line(s) shown
218
+
219
+ $ flownav read "Orphan Code" --lines 2-3
220
+ ```
221
+
222
+ - **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.
223
+ - **`--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.
224
+ - **`--lines 120-180`** (also `120` or `120-`): numbered lines, with a hint for the next slice. An open-ended range returns 60 lines.
225
+ - **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.
226
+ - **`--param`** reads another text parameter (for example an HTML field); without it, `read` uses `jsCode`, `pythonCode`, `functionCode` or `code`.
227
+
228
+ 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.
194
229
 
195
230
  ## Seeing the graph
196
231
 
@@ -252,6 +287,9 @@ flownav path <A> <B> [--max N --kinds a,b]
252
287
  flownav find [--type --cred --text --name --resource --workflow --match --disabled --trigger]
253
288
  flownav affected <node> --rename|--delete|--schema|--behavior
254
289
  flownav callers <workflow> | calls <workflow> | uses --cred <c>
290
+ flownav read <node> [--outline | --grep p [--regex --case --context n --max n] | --lines a-b]
291
+ [--param p --cols a-b --from path] read part of a Code node, capped and redacted
292
+ flownav stats [--from path] measure raw JSON tokens against flownav replies on your workflows
255
293
  flownav check [--only a,b] [--fail-on error|warn|info|none]
256
294
  checks: fragile-reads, dead-refs, cycles, unreachable, dead-ends, no-error-handler, webhook-no-respond
257
295
  flownav view [workflow] [--match text --collapse --focus <node> --depth N --out file.html --open]
@@ -295,6 +333,7 @@ For a manual "before I touch this" step, run `flownav affected "Node" --rename`
295
333
  - `pinData` (sample execution data) is dropped on load and never stored.
296
334
  - 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
335
  - Credentials are recorded by id and type only.
336
+ - `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
337
  - Execution data is not fetched; `--executions` records only the last status per workflow.
299
338
  - `.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
339
 
@@ -303,7 +342,7 @@ For a manual "before I touch this" step, run `flownav affected "Node" --rename`
303
342
  This is a back-of-the-envelope model, not a measurement of real agent sessions. Tokens are estimated as characters ÷ 4.
304
343
 
305
344
  - **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,300 tokens**: flownav's fixed overhead per session (the 11 MCP tool definitions, about 800 tokens, plus the skill, about 500).
345
+ - **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
346
  - **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
347
  - 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
348
  - raw: `C_raw ≈ W · (1.25 + 0.1·(Q − 1))`
@@ -316,25 +355,25 @@ Raw reading grows with the workflow; a flownav reply depends only on a node's lo
316
355
 
317
356
  | workflow size | raw tokens | 1 question | 10 questions |
318
357
  |---|---|---|---|
319
- | 5 nodes | 1,500 | 8% | −18% (costs more) |
320
- | 10 nodes | 3,000 | 54% | 41% |
321
- | 30 nodes | 9,000 | 85% | 80% |
322
- | 132 nodes | 40,000 | 97% | 96% |
323
- | whole instance (443 nodes) | 133,000 | 99% | 99% |
358
+ | 5 nodes | 1,500 | −12% (costs more) | −38% (costs more) |
359
+ | 10 nodes | 3,000 | 44% | 31% |
360
+ | 30 nodes | 9,000 | 81% | 77% |
361
+ | 132 nodes | 40,000 | 96% | 95% |
362
+ | whole instance (443 nodes) | 133,000 | 99% | 98% |
324
363
 
325
364
  **Saving when the agent searches and reads only a fraction of a 132-node workflow:**
326
365
 
327
366
  | fraction read | 1 question | 10 questions |
328
367
  |---|---|---|
329
- | 30% | 89% | 85% |
330
- | 20% | 83% | 78% |
331
- | 10% | 66% | 56% |
368
+ | 30% | 86% | 83% |
369
+ | 20% | 79% | 74% |
370
+ | 10% | 58% | 48% |
332
371
 
333
372
  What to take from it:
334
373
 
335
374
  - 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
375
  - 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. To get a real figure, 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.
376
+ - τ 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
377
  - Tokens are not the only benefit: `affected` finds `$('Name')` references in expressions and Code nodes that a skimming agent misses.
339
378
 
340
379
  ## 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/graph.js CHANGED
@@ -66,7 +66,14 @@ export class G {
66
66
  const hits = exact.length ? exact : part;
67
67
  if (!hits.length)
68
68
  throw new Error(`No workflow matches "${q}". Run flownav_scan to list workflows.`);
69
- const rows = hits.slice(0, 10).map((w, i) => ` ${i + 1}. ${w.name} [id ${w.id}, ${w.nodeCount} nodes]`);
69
+ const rows = hits.slice(0, 10).flatMap((w, i) => {
70
+ const trig = [...new Set(this.nodesOf(w.id).filter((n) => n.trigger).map((n) => shortType(n.type)))];
71
+ const state = w.active === undefined ? '' : w.active ? ', active' : ', inactive';
72
+ return [
73
+ ` ${i + 1}. ${w.name} [id ${w.id}, ${w.nodeCount} nodes${state}]`,
74
+ ` ${trig.length ? `started by: ${trig.slice(0, 4).join(', ')}` : 'no trigger of its own'}${w.errorWorkflow ? ' · has an error workflow' : ''}`,
75
+ ];
76
+ });
70
77
  if (hits.length > 10)
71
78
  rows.push(` ... and ${hits.length - 10} more`);
72
79
  throw new Error([`"${q}" matches ${hits.length} workflows. ${ASK} Then use its id.`, ...rows,
@@ -76,8 +83,32 @@ export class G {
76
83
  resolveNode(q) {
77
84
  return this.resolveNodeEx(q).node;
78
85
  }
86
+ /** One line of context so the agent can tell candidates apart and ask a sharper question. */
87
+ contextLine(n) {
88
+ const bits = [n.summary];
89
+ if (n.group)
90
+ bits.push(`group "${n.group}"`);
91
+ if (n.disabled)
92
+ bits.push('disabled');
93
+ const names = (ids) => [...new Set(ids.map((id) => this.nodeById.get(id)?.name ?? id))];
94
+ const from = names(this.inE(n.id, ['main', 'capability']).map((e) => e.from));
95
+ const into = names(this.outE(n.id, ['capability']).map((e) => e.to));
96
+ if (n.trigger)
97
+ bits.push('starts the workflow');
98
+ else if (from.length)
99
+ bits.push(`after: ${from.slice(0, 2).join(', ')}${from.length > 2 ? ` (+${from.length - 2})` : ''}`);
100
+ else if (into.length)
101
+ bits.push(`plugs into: ${into.slice(0, 2).join(', ')}`);
102
+ else
103
+ bits.push('not connected to anything');
104
+ const line = bits.join(' · ');
105
+ return line.length > 150 ? `${line.slice(0, 149)}…` : line;
106
+ }
79
107
  ambiguousNodes(q, hits) {
80
- const rows = hits.slice(0, 10).map((n, i) => ` ${i + 1}. ${this.g.workflows[n.wf]?.name ?? n.wf} › ${n.name} [${shortType(n.type)}] use: ${n.wf}:${n.name}`);
108
+ const rows = hits.slice(0, 10).flatMap((n, i) => [
109
+ ` ${i + 1}. ${this.g.workflows[n.wf]?.name ?? n.wf} › ${n.name} [${shortType(n.type)}] use: ${n.wf}:${n.name}`,
110
+ ` ${this.contextLine(n)}`,
111
+ ]);
81
112
  if (hits.length > 10)
82
113
  rows.push(` ... and ${hits.length - 10} more`);
83
114
  return [`"${q}" matches ${hits.length} nodes. ${ASK} Then use the workflow:node form.`, ...rows].join('\n');
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
- try {
67
- const r = tool.run(getG(), params.arguments ?? {});
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/dist/viewer.js CHANGED
@@ -126,10 +126,11 @@ aside li button{width:100%;text-align:left;margin:2px 0;border-radius:6px;overfl
126
126
  <script>
127
127
  (function () {
128
128
  var D = JSON.parse(document.getElementById('data').textContent);
129
- var NS = 'http://www.w3.org/2000/svg';
129
+ var svg = document.getElementById('svg'), vp = document.getElementById('vp'), panel = document.getElementById('panel');
130
+ // The page's own <svg> element already carries the SVG namespace; reuse it for new elements.
131
+ var NS = svg.namespaceURI;
130
132
  var NW = 176, NH = 38, COLW = 250, ROWH = 62, PAD = 36, HEAD = 34, GAP = 50;
131
133
  var LAYER = { main: 1, capability: 1, calls: 1, on_error: 1, webhook_calls: 1 };
132
- var svg = document.getElementById('svg'), vp = document.getElementById('vp'), panel = document.getElementById('panel');
133
134
 
134
135
  function el(tag, attrs, parent, text) {
135
136
  var e = document.createElementNS(NS, tag);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flownav",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
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",