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 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,300 tokens**: flownav's fixed overhead per session (the 11 MCP tool definitions, about 800 tokens, plus the skill, about 500).
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 | 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% |
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% | 89% | 85% |
330
- | 20% | 83% | 78% |
331
- | 10% | 66% | 56% |
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. 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.
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
- 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flownav",
3
- "version": "0.1.3",
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",